В большинстве примеров, рассмотренных до сих пор, создавались "автономные" исполняемые приложения, где вся программная логика упаковывалась в единственную сборку (
*.dll
) и выполнялась с применением dotnet.ехе
(или копии dotnet.ехе
, носящей имя сборки). Такие сборки использовали в основном библиотеки базовых классов .NET Core. В то время как некоторые простые программы .NET Core могут быть сконструированы с применением только библиотек базовых классов, многократно используемая программная логика нередко изолируется в специальных библиотеках классов (файлах *.dll
), которые могут разделяться между приложениями.
В настоящей главе вы сначала исследуете детали разнесения типов по пространствам имен .NET Core. После этого вы подробно ознакомитесь с библиотеками классов в .NET Core, выясните разницу между .NET Core и .NET Standard, а также научитесь конфигурировать приложения, публиковать консольные приложения .NET Core и упаковывать свои библиотеки в многократно используемые пакеты NuGet.
Прежде чем погружаться в детали развертывания и конфигурирования библиотек, сначала необходимо узнать, каким образом упаковывать свои специальные типы в пространства имен .NET Core. Вплоть до этого места в книге создавались небольшие тестовые программы, которые задействовали существующие пространства имен из мира .NET Core (в частности
System
). Однако когда строится крупное приложение со многими типами, возможно, будет удобно группировать связанные типы в специальные пространства имен. В C# такая цель достигается с применением ключевого слова namespace
. Явное определение специальных пространств имен становится еще более важным при построении разделяемых сборок, т.к. для использования ваших типов другие разработчики будут нуждаться в ссылке на вашу библиотеку и импортировании специальных пространств имен. Специальные пространства имен также предотвращают конфликты имен, отделяя ваши специальные классы от других специальных классов, которые могут иметь совпадающие имена.
Чтобы исследовать все аспекты непосредственно, начните с создания нового проекта консольного приложения .NET Core под названием
CustomNamespaces
. Предположим, что требуется разработать коллекцию геометрических классов с именами Square
(квадрат), Circle
(круг) и Hexagon
(шестиугольник). Учитывая сходные между ними черты, было бы желательно сгруппировать их в уникальном пространстве имен MyShapes
внутри сборки CustomNamespaces.ехе
.
Хотя компилятор C# без проблем воспримет единственный файл кода С#, содержащий множество типов, такой подход может стать проблематичным в командном окружении. Если вы работаете над типом
Circle
, а ваш коллега — над типом Hexagon
, тогда вам придется по очереди работать с монолитным файлом или сталкиваться с трудноразрешимыми (во всяком случае, отнимающими много времени) конфликтами при слиянии изменений.
Более удачный подход предусматривает помещение каждого класса в собственный файл, с определением в каждом из них пространства имен. Чтобы обеспечить упаковку типов в ту же самую логическую группу, просто помещайте заданные определения классов в область действия одного и того же пространства имен:
// Circle.cs
namespace MyShapes
{
// Класс Circle
public class Circle { /* Интересные методы... */ }
}
// Hexagon.cs
namespace MyShapes
{
// Hexagon class
public class Hexagon { /* Еще интересные методы... */ }
}
// Square.cs
namespace MyShapes
{
// Square class
public class Square { /* И еще интересные методы...*/}
}
На заметку! Рекомендуется иметь в каждом файле кода только один класс. В ряде приводимых ранее примеров такое правило не соблюдалось, но причиной было упрощение изучения. В последующих главах каждый класс по возможности будет располагаться в собственном файле кода.
Обратите внимание на то, что пространство
MyShapes
действует как концептуальный "контейнер" для определяемых в нем классов. Когда в другом пространстве имен (скажем, CustomNamespaces
) необходимо работать с типами из отдельного пространства имен, вы применяете ключевое слово using
, как поступали бы в случае использования пространств имен из библиотек базовых классов .NET Core:
// Обратиться к пространству имен из библиотек базовых классов.
using System;
// Использовать типы, определенные в пространстве имен MyShapes.
using MyShapes;
Hexagon h = new Hexagon();
Circle c = new Circle();
Square s = new Square();
В примере предполагается, что файлы С#, где определено пространство имен
MyShapes
, являются частью того же самого проекта консольного приложения; другими словами, все эти файлы компилируются в единственную сборку. Если пространство имен MyShapes
определено во внешней сборке, то для успешной компиляции потребуется также добавить ссылку на данную библиотеку. На протяжении настоящей главы вы изучите все детали построения приложений, взаимодействующих с внешними библиотеками.
Говоря формально, вы не обязаны применять ключевое слово
using
языка C# при ссылках на типы, определенные во внешних пространствах имен. Вы можете использовать полностью заданные имена типов, которые, как упоминалось в главе 1, представляют собой имена типов, предваренные названиями пространств имен, где типы определены. Например:
// Обратите внимание, что пространство имен MyShapes больше
не импортируется!
using System;
MyShapes.Hexagon h = new MyShapes.Hexagon();
MyShapes.Circle c = new MyShapes.Circle();
MyShapes.Square s = new MyShapes.Square();
Обычно необходимость в применении полностью заданных имен отсутствует. Они требуют большего объема клавиатурного ввода, но никак не влияют на размер кода и скорость выполнения. На самом деле в коде CIL типы всегда определяются с полностью заданными именами. С этой точки зрения ключевое слово
using
языка C# является просто средством экономии времени на наборе.
Тем не менее, полностью заданные имена могут быть полезными (а иногда и необходимыми) для избегания потенциальных конфликтов имен при использовании множества пространств имен, которые содержат идентично названные типы.
Предположим, что есть новое пространство имен
My3DShapes
, где определены три класса, которые способны визуализировать фигуры в трехмерном формате:
// Еще одно пространство имен для работы с фигурами.
// Circle.cs
namespace My3DShapes
{
// Класс для представления трехмерного круга.
public class Circle { }
}
// Hexagon.cs
namespace My3DShapes
{
// Класс для представления трехмерного шестиугольника.
public class Hexagon { }
}
// Square.cs
namespace My3DShapes
{
// Класс для представления трехмерного квадрата.
public class Square { }
}
Если теперь вы модифицируете операторы верхнего уровня, как показано ниже, то получите несколько ошибок на этапе компиляции, потому что в обоих пространствах имен определены одинаково именованные классы:
// Масса неоднозначностей!
using System;
using MyShapes;
using My3DShapes;
// На какое пространство имен производится ссылка?
Hexagon h = new Hexagon(); // Ошибка на этапе компиляции!
Circle c = new Circle(); // Ошибка на этапе компиляции!
Square s = new Square(); // Ошибка на этапе компиляции!
Устранить неоднозначности можно за счет применения полностью заданных имен:
// Теперь неоднозначности устранены.
My3DShapes.Hexagon h = new My3DShapes.Hexagon();
My3DShapes.Circle c = new My3DShapes.Circle();
MyShapes.Square s = new MyShapes.Square();
Ключевое слово
using
языка C# также позволяет создавать псевдоним для полностью заданного имени типа. В этом случае определяется метка, которая на этапе компиляции заменяется полностью заданным именем типа. Определение псевдонимов предоставляет второй способ разрешения конфликтов имен. Вот пример:
using System;
using MyShapes;
using My3DShapes;
// Устранить неоднозначность, используя специальный псевдоним.
using The3DHexagon = My3DShapes.Hexagon;
// На самом деле здесь создается экземпляр класса My3DShapes.Hexagon.
The3DHexagon h2 = new The3DHexagon();
...
Продемонстрированный альтернативный синтаксис
using
также дает возможность создавать псевдонимы для пространств имен с очень длинными названиями. Одним из пространств имен с самым длинным названием в библиотеках базовых классов является System.Runtime.Serialization.Formatters.Binary
, которое содержит член по имени BinaryFormatter
. При желании экземпляр класса BinaryFormatter
можно создать следующим образом:
using bfHome = System.Runtime.Serialization.Formatters.Binary;
bfHome.BinaryFormatter b = new bfHome.BinaryFormatter();
...
либо с использованием традиционной директивы
using
:
using System.Runtime.Serialization.Formatters.Binary;
BinaryFormatter b = new BinaryFormatter();
...
На данном этапе не нужно беспокоиться о предназначении класса
BinaryFormatter
(он исследуется в главе 20). Сейчас просто запомните, что ключевое слово using
в C# позволяет создавать псевдонимы для очень длинных полностью заданных имен или, как случается более часто, для разрешения конфликтов имен, которые могут возникать при импорте пространств имен, определяющих типы с идентичными названиями.
На заметку! Имейте в виду, что чрезмерное применение псевдонимов C# в результате может привести к получению запутанной кодовой базы. Если другие программисты в команде не знают о ваших специальных псевдонимах, то они могут полагать, что псевдонимы ссылаются на типы из библиотек базовых классов, и прийти в замешательство, не обнаружив их описания в документации.
При организации типов допускается определять пространства имен внутри других пространств имен. В библиотеках базовых классов подобное встречается во многих местах и обеспечивает размещение типов на более глубоких уровнях. Например, пространство имен
IO
вложено внутрь пространства имен System
, давая в итоге System.IO
.
Шаблоны проектов .NET Core помещают начальный код в файле
Program.cs
внутрь пространства имен, название которого совпадает с именем проекта. Такое базовое пространство имен называется корневым. В этом примере корневым пространством имен, созданным шаблоном .NET Core, является CustomNamespaces
:
namespace CustomNamespaces
{
class Program
{
...
}
}
На заметку! В случае замены комбинации
Program/Main()
операторами верхнего уровня назначить им какое-либо пространство имен не удастся.
Вложить пространства имен
MyShapes
и My3DShapes
внутрь корневого пространства имен можно двумя способами. Первый — просто вложить ключевое слово namespace
, например:
namespace CustomNamespaces
{
namespace MyShapes
{
// Класс Circle
public class Circle
{
/* Интересные методы... */
}
}
}
Второй (и более распространенный) способ предусматривает использование "точечной записи" в определении пространства имен, как показано ниже:
namespace CustomNamespaces.MyShapes
{
// Класс Circle
public class Circle
{
/* Интересные методы... */
}
}
Пространства имен не обязаны содержать какие-то типы непосредственно, что позволяет применять их для обеспечения дополнительного уровня области действия.
Учитывая, что теперь пространство
My3DShapes
вложено внутрь корневого пространства имен CustomNamespaces
, вам придется обновить все существующие директивы using
и псевдонимы типов (при условии, что вы модифицировали все примеры классов с целью их вложения внутрь корневого пространства имен):
using The3DHexagon = CustomNamespaces.My3DShapes.Hexagon;
using CustomNamespaces.MyShapes;
На заметку! На практике принято группировать файлы в пространстве имен по каталогам. Вообще говоря, расположение файла в рамках структуры каталогов никак не влияет на пространства имен. Однако такой подход делает структуру пространств имен более ясной (и конкретной) для других разработчиков. По этой причине многие разработчики и инструменты анализа кода ожидают соответствия пространств имен структуре каталогов.
Как упоминалось ранее, при создании нового проекта C# с использованием Visual Studio (либо интерфейса .NET Core CLI) название корневого пространства имен приложения будет совпадать с именем проекта. Когда затем в Visual Studio к проекту добавляются новые файлы кода с применением пункта меню Project►Add New Item (Проекта►Добавить новый элемент), типы будут автоматически помещаться внутрь корневого пространства имен. Если вы хотите изменить название корневого пространства имен, тогда откройте окно свойств проекта, перейдите в нем на вкладку Application (Приложение) и введите желаемое имя в поле Default namespace (Стандартное пространство имен), как показано на рис. 16.1.
На заметку! В окне свойств проекта Visual Studio корневое пространство имен по-прежнему представлено как стандартное (default). Далее вы увидите, почему в книге оно называется корневым (root) пространством имен.
Конфигурировать корневое пространство имен можно также путем редактирования файла проекта (
*.csproj
). Чтобы открыть файл проекта .NET Core, дважды щелкните на его имени в окне Solution Explorer или щелкните на нем правой кнопкой мыши и выберите в контекстном меню пункт Edit project file (Редактировать файл проекта). После открытия файла обновите главный узел PropertyGroup
, добавив узел RootNamespace
:
Exe
net5.0
CustomNamespaces2
Теперь, когда вы ознакомились с некоторыми деталями упаковки специальных типов в четко организованные пространства имен, давайте кратко рассмотрим преимущества и формат сборки .NET Core. Затем мы углубимся в подробности создания, развертывания и конфигурирования специальных библиотек классов.
Приложения .NET Core конструируются путем соединения в одно целое любого количества сборок. Выражаясь просто, сборка представляет собой самоописательный двоичный файл, который поддерживает версии и обслуживается средой .NET Core Runtime. Невзирая на то, что сборки .NET Core имеют такие же файловые расширения (
*.ехе
или *.dll
), как и старые двоичные файлы Windows, в их внутренностях мало общего. Таким образом, первым делом давайте выясним, какие преимущества предлагает формат сборки.
При построении проектов консольных приложений в предшествующих главах могло показаться, что вся функциональность приложений содержалась внутри конструируемых исполняемых сборок. В действительности примеры приложений задействовали многочисленные типы из всегда доступных библиотек базовых классов .NET Core.
Возможно, вы уже знаете, что библиотека кода (также называемая библиотекой классов) — это файл
*.dll
, который содержит типы, предназначенные для применения внешними приложениями. При построении исполняемых сборок вы без сомнения будете использовать много системных и специальных библиотек кода по мере создания приложений. Однако имейте в виду, что библиотека кода необязательно должна получать файловое расширение *.dll
. Вполне допускается (хотя нечасто), чтобы исполняемая сборка работала с типами, определенными внутри внешнего исполняемого файла. В таком случае ссылаемый файл *.ехе
также может считаться библиотекой кода.
Независимо от того, как упакована библиотека кода, платформа .NET Core позволяет многократно применять типы в независимой от языка манере. Например, вы могли бы создать библиотеку кода на C# и повторно использовать ее при написании кода на другом языке программирования .NET Core. Между языками есть возможность не только выделять память под экземпляры типов, но также и наследовать от самих типов. Базовый класс, определенный в С#, может быть расширен классом, написанным на Visual Basic. Интерфейсы, определенные в F#, могут быть реализованы структурами, определенными в С#, и т.д. Важно понимать, что за счет разбиения единственного монолитного исполняемого файла на несколько сборок .NET Core достигается возможность многократного использования кода в форме, нейтральной к языку.
Вспомните, что полностью заданное имя типа получается за счет предварения имени этого типа (
Console
) названием пространства имен, где он определен (System
). Тем не менее, выражаясь строго, удостоверение типа дополнительно устанавливается сборкой, в которой он находится. Например, если есть две уникально именованных сборки (MyCars.dll
и YourCars.dll
), которые определяют пространство имен (CarLibrary
), содержащее класс по имени SportsCar
, то в мире .NET Core такие типы SportsCar
будут рассматриваться как уникальные.
Сборкам .NET Core назначается состоящий из четырех частей числовой номер версии в форме <старший номер>.<младший номер>.<номер сборки>.<номер редакции>. (Если номер версии явно не указан, то сборке автоматически назначается версия 1.0.0.0 из-за стандартных настроек проекта в .NET Core.) Этот номер позволяет множеству версий той же самой сборки свободно сосуществовать на одной машине.
Сборки считаются самоописательными отчасти из-за того, что в своем манифесте содержат информацию обо всех внешних сборках, к которым они должны иметь доступ для корректного функционирования. Вспомните из главы 1, что манифест представляет собой блок метаданных, которые описывают саму сборку (имя, версия, обязательные внешние сборки и т.д.).
В дополнение к данным манифеста сборка содержит метаданные, которые описывают структуру каждого содержащегося в ней типа (имена членов, реализуемые интерфейсы, базовые классы, конструкторы и т.п.). Благодаря тому, что сборка настолько детально документирована, среда .NET Core Runtime не нуждается в обращении к реестру Windows для выяснения ее местонахождения (что радикально отличается от унаследованной модели программирования СОМ от Microsoft). Такое отделение от реестра является одним из факторов, которые позволяют приложениям .NET Core функционировать под управлением других операционных систем (ОС) помимо Windows, а также обеспечивают поддержку на одной машине множества версий платформы .NET Core.
В текущей главе вы узнаете, что для получения информации о местонахождении внешних библиотек кода среда .NET Core Runtime применяет совершенно новую схему.
Теперь, когда вы узнали о многих преимуществах сборок .NET Core, давайте более детально рассмотрим, как такие сборки устроены внутри. Говоря о структуре, сборка .NET Core (
*.dll
или *.ехе
) состоит из следующих элементов:
• заголовок файла ОС (например, Windows);
• заголовок файла CLR;
• код CIL;
• метаданные типов;
• манифест сборки;
• дополнительные встроенные ресурсы.
Несмотря на то что первые два элемента (заголовки ОС и CLR) представляют собой блоки данных, которые обычно можно игнорировать, краткого рассмотрения они все же заслуживают. Ниже приведен обзор всех перечисленных элементов.
В последующих нескольких разделах используется утилита по имени
dumpbin.ехе
, которая входит в состав инструментов профилирования C++. Чтобы установить их, введите C++ profiling в поле быстрого поиска и щелкните на подсказке Install C++ profiling tools (Установить инструменты профилирования C++), как показано на рис. 16.2.
В результате запустится установщик Visual Studio с выбранными инструментами. В качестве альтернативы можете запустить установщик Visual Studio самостоятельно и выбрать необходимые компоненты (рис. 16.3).
Заголовок файла ОС устанавливает факт того, что сборку можно загружать и манипулировать ею в среде целевой ОС (Windows в рассматриваемом примере). Данные в этом заголовке также идентифицируют вид приложения (консольное, с графическим пользовательским интерфейсом или библиотека кода
*.dll
), которое должно обслуживаться ОС.
Откройте файл
CarLibrary.dll
(в хранилище GitHub для книги или созданный позже в главе) с применением утилиты dumpbin.ехе
(в окне командной строки разработчика), указав ей флаг /headers
:
dumpbin /headers CarLibrary.dll
В результате отобразится информация заголовка файла ОС сборки, построенной для Windows, часть которой показана ниже:
Dump of file carlibrary.dll
PE signature found
File Type: DLL
FILE HEADER VALUES
14C machine (x86)
3 number of sections
BB89DC3D time date stamp
0 file pointer to symbol table
0 number of symbols
E0 size of optional header
2022 characteristics
Executable
Application can handle large (>2GB) addresses
DLL
...
Дамп файла CarLibrary.dll
Обнаружена подпись РЕ
Тип файла: DLL
Значения заголовка файла
14С машина (х86)
3 количество разделов
BB89DC3D дата и время
0 файловый указатель на таблицу символов
0 количество символов
Е0 размер необязательного заголовка
2022 характеристики
Исполняемый файл
Приложение может обрабатывать большие (> 2 Гбайт) адреса
DLL
...
Запомните, что подавляющему большинству программистов, использующих .NET Core, никогда не придется беспокоиться о формате данных заголовков, встроенных в сборку .NET Core. Если только вы не занимаетесь разработкой нового компилятора языка .NET Core (в таком случае вы обязаны позаботиться о подобной информации), то можете не вникать в тонкие детали заголовков. Однако помните, что такая информация потребляется "за кулисами", когда ОС загружает двоичный образ в память.
Заголовок файла CLR — это блок данных, который должны поддерживать все сборки .NET Core (и благодаря компилятору C# они его поддерживают), чтобы обслуживаться средой .NET Core Runtime. Выражаясь кратко, в заголовке CLR определены многочисленные флаги, которые позволяют исполняющей среде воспринимать компоновку управляемого файла. Например, существуют флаги, идентифицирующие местоположение метаданных и ресурсов внутри файла, версию исполняющей среды, для которой была построена сборка, значение (необязательного) открытого ключа и т.д. Снова запустите утилиту
dumpbin.exe
, указав флаг /clrheader
:
dumpbin /clrheader CarLibrary.dll
Вы увидите внутреннюю информацию заголовка файла CLR для заданной сборки .NET Core:
Dump of file CarLibrary.dll
File Type: DLL
clr Header:
48 cb
2.05 runtime version
2158 [ B7C] RVA [size] of MetaData Directory
1 flags
IL Only
0 entry point token
0 [ 0] RVA [size] of Resources Directory
0 [ 0] RVA [size] of StrongNameSignature Directory
0 [ 0] RVA [size] of CodeManagerTable Directory
0 [ 0] RVA [size] of VTableFixups Directory
0 [ 0] RVA [size] of ExportAddressTableJumps Directory
0 [ 0] RVA [size] of ManagedNativeHeader Directory
Summary
2000 .reloc
2000 .rsrc
2000 .text
Дамп файла CarLibrary.dll
Тип файла : DLL
Заголовок clr:
48 cb
2.05 версия исполняющей среды
2158 [ B7C] RVA [size] каталога MetaData
1 флаги
Только IL
0 маркер записи
0 [ 0] RVA [size] каталога Resources
0 [ 0] RVA [size] каталога StrongNameSignature
0 [ 0] RVA [size] каталога CodeManagerTable
0 [ 0] RVA [size] каталога VTableFixups
0 [ 0] RVA [size] каталога ExportAddressTableJumps
0 [ 0] RVA [size] каталога ManagedNativeHeader
Сводка
2000 .reloc
2000 .rsrc
2000 .text
И снова важно отметить, что вам как разработчику приложений .NET Core не придется беспокоиться о тонких деталях информации заголовка файла CLR. Просто знайте, что каждая сборка .NET Core содержит данные такого рода, которые исполняющая среда .NET Core использует "за кулисами" при загрузке образа в память. Теперь переключите свое внимание на информацию, которая является намного более полезной при решении повседневных задач программирования.
В своей основе сборка содержит код CIL, который, как вы помните, представляет собой промежуточный язык, не зависящий от платформы и процессора. Во время выполнения внутренний код CIL на лету посредством JIT-компилятора компилируется в инструкции, специфичные для конкретной платформы и процессора. Благодаря такому проектному решению сборки .NET Core действительно могут выполняться под управлением разнообразных архитектур, устройств и ОС. (Хотя вы можете благополучно и продуктивно работать, не разбираясь в деталях языка программирования CIL, в главе 19 предлагается введение в синтаксис и семантику CIL.)
Сборка также содержит метаданные, полностью описывающие формат внутренних типов и формат внешних типов, на которые сборка ссылается. Исполняющая среда .NET Core применяет эти метаданные для выяснения местоположения типов (и их членов) внутри двоичного файла, для размещения типов в памяти и для упрощения удаленного вызова методов. Более подробно детали формата метаданных .NET Core будут раскрыты в главе 17 во время исследования служб рефлексии.
Сборка должна также содержать связанный с ней манифест (по-другому называемый метаданными сборки). Манифест документирует каждый модуль внутри сборки, устанавливает версию сборки и указывает любые внешние сборки, на которые ссылается текущая сборка. Как вы увидите далее в главе, исполняющая среда .NET Core интенсивно использует манифест сборки в процессе нахождения ссылок на внешние сборки.
Наконец, сборка .NET Core может содержать любое количество встроенных ресурсов, таких как значки приложения, файлы изображений, звуковые клипы или таблицы строк. На самом деле платформа .NET Core поддерживает подчиненные сборки, которые содержат только локализованные ресурсы и ничего другого. Они могут быть удобны, когда необходимо отделять ресурсы на основе культуры (русской, немецкой, английской и т.д.) при построении интернационального программного обеспечения. Тема создания подчиненных сборок выходит за рамки настоящей книги; если вам интересно, обращайтесь за информацией о подчиненных сборках и локализации в документацию по .NET Core.
До сих пор в этой книге почти все примеры были консольными приложениями .NET Core. При наличии опыта разработки для .NET, вы заметите, что они похожи на консольные приложения .NET. Основное отличие касается процесса конфигурирования (рассматривается позже), а также того, что они выполняются под управлением .NET Core. Консольные приложения имеют единственную точку входа (либо указанный метод
Main()
, либо операторы верхнего уровня), способны взаимодействовать с консолью и могут запускаться прямо из среды ОС. Еще одно отличие между консольными приложениями .NET Core и .NET связано с тем, что консольные приложения в .NET Core запускаются с применением хоста приложений .NET Core (dotnet.exe
).
С другой стороны, библиотеки классов не имеют точки входа и потому не могут запускаться напрямую. Они используются для инкапсуляции логики, специальных типов и т.п., а ссылка на них производится из других библиотек классов и/или консольных приложений. Другими словами, библиотеки классов применяются для хранения всего того, о чем шла речь в разделе "Роль сборок .NET Core" ранее в главе.
Библиотеки классов .NET Core функционируют под управлением .NET Core, а библиотеки классов .NET — под управлением .NET. Все довольно просто. Тем не менее, здесь имеется проблема. Предположим, что ваша организация располагает крупной кодовой базой .NET, разрабатываемой в течение (потенциально) многих лет вами и коллегами по команде. Возможно, существует совместно используемый код значительного объема, задействованный в приложениях, которые вы и ваша команда создали за прошедшие годы. Вполне вероятно, что этот код реализует централизованное ведение журнала, формирование отчетов или функциональность, специфичную для предметной области.
Теперь вы (вместе с вашей организацией) хотите вести разработку новых приложений с применением .NET Core. А что делать со всем совместно используемым кодом? Переписывание унаследованного кода для его помещения в сборки .NET Core может требовать значительных усилий. Вдобавок до тех пор, пока все ваши приложения не будут перенесены в .NET Core, вам придется поддерживать две версии (одну в .NET и одну в .NET Core), что приведет к резкому снижению продуктивности.
К счастью, разработчики платформы .NET Core продумали такой сценарий. В .NET Core появился .NET Standard — новый тип проекта библиотеки классов, на которую можно ссылаться в приложениях как .NET, так и .NET Core. Однако прежде чем выяснять, оправданы ли ваши ожидания, следует упомянуть об одной загвоздке с .NET (Core) 5, которая будет рассмотрена чуть позже.
В каждой версии .NET Standard определен общий набор API-интерфейсов, которые должны поддерживаться всеми версиями .NET (.NET, .NET Core, Xamarin и т.д.), чтобы удовлетворять требованиям стандарта. Например, если бы вы строили библиотеку классов как проект .NET Standard 2.0, то на нее можно было бы ссылаться из .NET 4.6 .1+ и .NET Core 2.0+ (плюс разнообразные версии Xamarin, Mono, Universal Windows Platform и Unity).
Это означает, что вы могли бы перенести код из своих библиотек классов .NET в библиотеки классов .NET Standard 2.0 и совместно использовать их в приложениях .NET Core и .NET Такое решение гораздо лучше, чем поддержка двух копий того же самого кода, по одной для каждой платформы.
А теперь собственно о загвоздке. Каждая версия .NET Standard представляет собой наименьший общий знаменатель для платформ, которые она поддерживает, т.е. чем ниже версия, тем меньше вы можете делать в своей библиотеке классов.
Хотя в .NET (Core) 5 и .NET Core 3.1 можно ссылаться на библиотеку .NET Standard 2.0, в такой библиотеке вам не удастся задействовать существенное количество функциональных средств C# 8.0 (или любых средств C# 9.0). Для полной поддержки C# 8.0 и C# 9.0 вы должны применять .NET Standard 2.1, a .NET Standard 2.0 подходит только для .NET 4.8 (самая поздняя/последняя версия первоначальной инфраструктуры .NET Framework).
Итак, .NET Standard — все еще хороший механизм для использования существующего кода в более новых приложениях, но он не является панацеей.
В то время как всю информацию, необходимую вашему приложению .NET Core, допускается хранить в исходном коде, наличие возможности изменять определенные значения во время выполнения жизненно важно в большинстве приложений. Обычно это делается посредством конфигурационного файла, который поставляется вместе с приложением.
На заметку! В предшествующих версиях .NET Framework конфигурация приложений базировалась на файле XML по имени
арр.config
(или web.config
для приложений ASP.NET). Хотя конфигурационные XML-файлы по-прежнему можно применять, как будет показано в текущем разделе, главный способ конфигурирования приложений .NET Core предусматривает использование файлов JSON (JavaScript Object Notation — запись объектов JavaScript). Конфигурация будет подробно обсуждаться в главах, посвященных WPF и ASP.NET Core.
Чтобы ознакомиться с процессом, создайте новый проект консольного приложения .NET Core по имени
FunWithConfiguration
и добавьте к нему ссылку на пакет Microsoft.Extensions.Configuration.Json
:
dotnet new console -lang c# -n FunWithConfiguration
-o .\FunWithConfiguration -f net5.0
dotnet add FunWithConfiguration
package Microsoft.Extensions.Configuration.Json
Команды добавят к вашему проекту ссылку на подсистему конфигурации .NET Core, основанную на файлах JSON (вместе с необходимыми зависимостями). Чтобы задействовать ее, добавьте в проект новый файл JSON по имени
appsettings.json
. Модифицируйте файл проекта, обеспечив копирование этого файла в выходной каталог при каждой компиляции проекта:
Always
Приведите содержимое файла
appsettings.json
к следующему виду:
{
"CarName": "Suzy"
}
На заметку! Если вы не знакомы с форматом JSON, то знайте, что он представляет собой формат с парами "имя-значение" и объектами, заключенными в фигурные скобки. Целый файл может быть прочитан как один объект, а подобъекты тоже помечаются с помощью фигурных скобок. Позже в книге вы будете иметь дело с более сложными файлами JSON.
Финальный шаг связан с чтением конфигурационного файла и получением значения
CarName
. Обновите операторы using
в файле Program.cs
, как показано ниже:
using System;
using System.IO;
using Microsoft.Extensions.Configuration;
Модифицируйте метод
Main()
следующим образом:
static void Main(string[] args)
{
IConfiguration config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", true, true)
.Build();
}
Новая подсистема конфигурации начинается с создания экземпляра класса
ConfigurationBuilder
. Он позволяет добавлять множество файлов, устанавливать свойства (такие как местоположение конфигурационных файлов) и, в конце концов, встраивать конфигурацию внутрь экземпляра реализации интерфейса IConfiguration
.
Имея экземпляр реализации
IConfiguration
, вы можете обращаться с ним так, как принято в версии .NET 4.8. Добавьте приведенный далее код в конец метода Main()
и после запуска приложения вы увидите, что значение будет выведено на консоль:
Console.WriteLine($"My car's name is {config["CarName"]}");
Console.ReadLine();
В дополнение к файлам JSON существуют пакеты для поддержки переменных среды, Azure Key Vault, аргументов командной строки и многого другого. Подробные сведения ищите в документации по .NET Core.
Чтобы заняться исследованием мира библиотек классов .NET Core, будет создана сборка
*.dll
(по имени CarLibrary
), содержащая небольшой набор открытых типов. Для начала создайте решение. Затем создайте проект библиотеки классов по имени CarLibrary
и добавьте его в решение, если это еще не делалось.
dotnet new sln -n Chapter16_AllProjects
dotnet new classlib -lang c# -n CarLibrary -o .\CarLibrary -f net5.0
dotnet sln .\Chapter16_AllProjects.sln add .\CarLibrary
Первая команда создает в текущем каталоге пустой файл решения по имени
Chapterl6_AllProjects
(-n
). Вторая команда создает новый проект библиотеки классов .NET 5.0 (-f
) под названием CarLibrary
(-n
) в подкаталоге CarLibrary
(-о
). Указывать выходной подкаталог (-о
) необязательно. Если он опущен, то проект будет создан в подкаталоге с таким же именем, как у проекта. Третья команда добавляет новый проект к решению.
На заметку! Интерфейс командной строки .NET Core снабжен хорошей справочной системой. Для получения сведений о любой команде укажите с ней
-h
. Например, чтобы увидеть все шаблоны, введите dotnet new -h
. Для получения дополнительной информации о создании проекта библиотеки классов введите dotnet new classlib -h
.
После создания проекта и решения вы можете открыть его в Visual Studio (или Visual Studio Code), чтобы приступить к построению классов. Открыв решение, удалите автоматически сгенерированный файл
Class1.cs
. Проектное решение библиотеки для работы с автомобилями начинается с создания перечислений EngineStateEnum
и MusicMediaEnum
. Добавьте в проект два файла с именами MusicMediaEnum.cs
и EngineStateEnum.cs
и поместите в них следующий код:
// MusicMediaEnum.cs
namespace CarLibrary
{
// Тип музыкального проигрывателя, установленный в данном автомобиле.
public enum MusicMediaEnum
{
MusicCd,
MusicTape,
MusicRadio,
MusicMp3
}
}
// EngineStateEnum.cs
namespace CarLibrary
{
// Представляет состояние двигателя.
public enum EngineStateEnum
{
EngineAlive,
EngineDead
}
}
Далее создайте абстрактный базовый класс по имени
Car
, который определяет разнообразные данные состояния через синтаксис автоматических свойств. Класс Car
также имеет единственный абстрактный метод TurboBoost()
, в котором применяется специальное перечисление (EngineState
), представляющее текущее состояние двигателя автомобиля. Вставьте в проект новый файл класса C# по имени Car.cs
со следующим кодом:
namespace CarLibrary
{
// Абстрактный базовый класс в иерархии.
public abstract class Car
{
public string PetName {get; set;}
public int CurrentSpeed {get; set;}
public int MaxSpeed {get; set;}
protected EngineStateEnum State = EngineStateEnum.EngineAlive;
public EngineStateEnum EngineState => State;
public abstract void TurboBoost();
protected Car(){}
protected Car(string name, int maxSpeed, int currentSpeed)
{
PetName = name;
MaxSpeed = maxSpeed;
CurrentSpeed = currentSpeed;
}
}
}
Теперь предположим, что есть два непосредственных потомка класса
Car
с именами MiniVan
(минивэн) и SportsCar
(спортивный автомобиль). В каждом из них абстрактный метод TurboBoost()
переопределяется для отображения подходящего сообщения в окне консоли. Вставьте в проект два новых файла классов C# с именами MiniVan.cs
и SportsCar.cs
. Поместите в них показанный ниже код:
// SportsCar.cs
using System;
namespace CarLibrary
{
public class SportsCar : Car
{
public SportsCar(){ }
public SportsCar(
string name, int maxSpeed, int currentSpeed)
: base (name, maxSpeed, currentSpeed){ }
public override void TurboBoost()
{
Console.WriteLine("Ramming speed! Faster is better...");
}
}
}
// MiniVan.cs
using System;
namespace CarLibrary
{
public class MiniVan : Car
{
public MiniVan(){ }
public MiniVan(
string name, int maxSpeed, int currentSpeed)
: base (name, maxSpeed, currentSpeed){ }
public override void TurboBoost()
{
// Минивэны имеют плохие возможности ускорения!
State = EngineStateEnum.EngineDead;
Console.WriteLine("Eek! Your engine block exploded!");
}
}
}
Перед использованием
CarLibrary.dll
в клиентском приложении давайте посмотрим, как библиотека кода устроена внутри. Предполагая, что проект был скомпилирован, запустите утилиту ildasm.exe
со скомпилированной сборкой. Если у вас нет утилиты ildasm.exe
(описанной ранее в книге), то она также находится в каталоге для настоящей главы внутри хранилища GitHub.
ildasm /all /METADATA /out=CarLibrary.il
.\CarLibrary\bin\Debug\net5.0\CarLibrary.dll
Раздел манифеста
Manifest
дизассемблированных результатов начинается со строки //Metadata version: 4.0.30319
. Непосредственно за ней следует список всех внешних сборок, требуемых для библиотеки классов:
// Metadata version: v4.0.30319
.assembly extern System.Runtime
{
.publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
.ver 5:0:0:0
}
.assembly extern System.Console
{
.publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
.ver 5:0:0:0
}
Каждый блок
.assembly extern
уточняется директивами .publickeytoken
и .ver
. Инструкция .publickeytoken
присутствует только в случае, если сборка была сконфигурирована со строгим именем. Маркер .ver
определяет числовой идентификатор версии ссылаемой сборки.
На заметку! Предшествующие версии .NET Framework в большой степени полагались на назначение строгих имен, которые вовлекали комбинацию открытого и секретного ключей. Это требовалось в среде Windows для сборок, подлежащих добавлению в глобальный кеш сборок, но с выходом .NET Core необходимость в строгих именах значительно снизилась.
После ссылок на внешние сборки вы обнаружите несколько маркеров .custom, которые идентифицируют атрибуты уровня сборки (кроме маркеров, сгенерированных системой, также информацию об авторском праве, название компании, версию сборки и т.д.). Ниже приведена (совсем) небольшая часть этой порции данных манифеста:
.assembly CarLibrary
{
...
.custom instance void ... TargetFrameworkAttribute ...
.custom instance void ... AssemblyCompanyAttribute ...
.custom instance void ... AssemblyConfigurationAttribute ...
.custom instance void ... AssemblyFileVersionAttribute ...
.custom instance void ... AssemblyProductAttribute ...
.custom instance void ... AssemblyTitleAttribute ...
Такие настройки могут устанавливаться либо с применением окна свойств проекта в Visual Studio, либо путем редактирования файла проекта и добавления надлежащих элементов. Находясь в среде Visual Studio, щелкните правой кнопкой мыши на имени проекта в окне Solution Explorer, выберите в контекстном меню пункт Properties (Свойства) и перейдите не вкладку Package (Пакет) в левой части открывшегося диалогового окна (рис. 16.4).
Добавить метаданные к сборке можно и прямо в файле проекта
*.csproj
. Следующее обновление главного узла PropertyGroup
в файле проекта приводит к тому же результату, что и заполнение формы, представленной на рис. 16.4:
net5.0
Copyright 2020
Phil Japikse
Apress
Pro C# 9.0
CarLibrary
This is an awesome library for cars.
1.0.0.1
1.0.0.2
1.0.0.3
На заметку! Остальные поля информации о сборке на рис. 16.4 (и в показанном выше содержимом файла проекта) используются при генерировании пакетов NuGet из вашей сборки. Данная тема раскрывается позже в главе.
Вспомните, что сборка не содержит инструкций, специфичных для платформы; взамен в ней хранятся инструкции на независимом от платформы общем промежуточном языке (Common Intermediate Language — CIL). Когда исполняющая среда .NET Core загружает сборку в память, ее внутренний код CIL компилируется (с использованием JIT-компилятора) в инструкции, воспринимаемые целевой платформой. Например, метод
TurboBoost()
класса SportsCar
представлен следующим кодом CIL:
.method public hidebysig virtual
instance void TurboBoost() cil managed
{
.maxstack 8
IL_0000: nop
IL_0001: ldstr "Ramming speed! Faster is better..."
IL_0006: call void [System.Console]System.Console::WriteLine(string)
IL_000b: nop
IL_000c: ret
}
// end of method SportsCar::TurboBoost
Большинству разработчиков приложений .NET Core нет необходимости глубоко погружаться в детали кода CIL. В главе 19 будут приведены дополнительные сведения о синтаксисе и семантике языка CIL, которые могут быть полезны при построении более сложных приложений, требующих расширенных действий вроде конструирования сборок во время выполнения.
Прежде чем приступить к созданию приложений, в которых задействована ваша специальная библиотека .NET Core, давайте займемся исследованием метаданных для типов внутри сборки
CarLibrary.dll
. Скажем, вот определение TypeDef
для типа EnginestateEnum
:
TypeDef #1 (02000002)
-------------------------------------------------------
TypDefName: CarLibrary.EngineStateEnum
Flags : [Public] [AutoLayout] [Class] [Sealed] [AnsiClass]
Extends : [TypeRef] System.Enum
Field #1
-------------------------------------------------------
Field Name: value__
Flags : [Public] [SpecialName] [RTSpecialName]
CallCnvntn: [FIELD]
Field type: I4
Field #2
-------------------------------------------------------
Field Name: EngineAlive
Flags : [Public] [Static] [Literal] [HasDefault]
DefltValue: (I4) 0
CallCnvntn: [FIELD]
Field type: ValueClass CarLibrary.EngineStateEnum
Field #3
-------------------------------------------------------
Field Name: EngineDead
Flags : [Public] [Static] [Literal] [HasDefault]
DefltValue: (I4) 1
CallCnvntn: [FIELD]
Field type: ValueClass CarLibrary.EngineStateEnum
Как будет объясняться в следующей главе, метаданные сборки являются важным элементом платформы .NET Core и служат основой для многочисленных технологий (сериализация объектов, позднее связывание, расширяемые приложения и т.д.). В любом случае теперь, когда вы заглянули внутрь сборки
CarLibrary.dll
, можно приступать к построению клиентских приложений, в которых будут применяться типы из сборки.
Поскольку все типы в
CarLibrary
были объявлены с ключевым словом public
, другие приложения .NET Core имеют возможность пользоваться ими. Вспомните, что типы могут также определяться с применением ключевого слова internal
языка C# (на самом деле это стандартный режим доступа в C# для классов). Внутренние типы могут использоваться только в сборке, где они определены. Внешние клиенты не могут ни видеть, ни создавать экземпляры типов, помеченных ключевым словом internal
.
На заметку! Исключением из указанного правила является ситуация, когда сборка явно разрешает доступ другой сборке с помощью атрибута
InternalsVisibleTo
, который вскоре будет рассмотрен.
Чтобы воспользоваться функциональностью вашей библиотеки, создайте в том же решении, где находится
CarLibrary
, новый проект консольного приложения C# по имени CSharpCarClient
. Вы можете добиться цели с применением Visual Studio (щелкнув правой кнопкой мыши на имени решения и выбрав в контекстном меню пункт Add►New Project (Добавить►Новый проект)) или командной строки (ниже показаны три команды, выполняемые по отдельности):
dotnet new console -lang c# -n CSharpCarClient -o .\CSharpCarClient -f net5.0
dotnet add CSharpCarClient reference CarLibrary
dotnet sln .\Chapter16_AppRojects.sln add .\CSharpCarClient
Приведенные команды создают проект консольного приложения, добавляют к нему ссылку на проект
CarLibrary
и вставляют его в имеющееся решение.
На заметку! Команда
add reference
создает ссылку на проект, что удобно на этапе разработки, т.к. CSharpCarClient
будет всегда использовать последнюю версию CarLibrary
. Можно также ссылаться прямо на сборку. Прямые ссылки создаются за счет указания скомпилированной библиотеки классов.
Если решение все еще открыто в Visual Studio, тогда вы заметите, новый проект отобразится в окне Solution Explorer безо всякого вмешательства с вашей стороны.
Наконец, щелкните правой кнопкой мыши на имени
CSharpCarClient
в окне Solution Explorer и выберите в контекстном меню пункт Set as Startup Project (Установить как стартовый проект). Если вы не работаете в Visual Studio, то можете запустить новый проект, введя команду dotnet run
в каталоге проекта.
На заметку! Для установки ссылки на проект в Visual Studio можно также щелкнуть правой кнопкой мыши на имени проекта
CSharpCarClient
в окне Solution Explorer, выбрать в контекстном меню пункт Add► Reference (Добавить►Ссылка) и указать CarLibrary
в узле проекта.
Теперь вы можете строить клиентское приложение для использования внешних типов. Модифицируйте начальный файл кода С#, как показано ниже:
using System;
// Не забудьте импортировать пространство имен CarLibrary!
using CarLibrary;
Console.WriteLine("***** C# CarLibrary Client App *****");
// Создать объект SportsCar.
SportsCar viper = new SportsCar("Viper", 240, 40);
viper.TurboBoost();
// Создать объект MiniVan.
MiniVan mv = new MiniVan();
mv.TurboBoost();
Console.WriteLine("Done. Press any key to terminate");
// Готово. Нажмите любую клавишу для прекращения работы
Console.ReadLine();
Код выглядит очень похожим на код в других приложениях, которые разрабатывались в книге ранее. Единственный интересный аспект связан с тем, что в клиентском приложении C# теперь применяются типы, определенные внутри отдельной специальной библиотеки. Запустив приложение, можно наблюдать отображение разнообразных сообщений.
Вас может интересовать, что в точности происходит при ссылке на проект
CarLibrary
. Когда создается ссылка на проект, порядок компиляции решения корректируется таким образом, чтобы зависимые проекты (CarLibrary
в рассматриваемом примере) компилировались первыми и результат компиляции копировался в выходной каталог родительского проекта (CSharpCarLibrary
). Скомпилированная клиентская библиотека ссылается на скомпилированную библиотеку классов. При повторной компиляции клиентского проекта то же самое происходит и с зависимой библиотекой, так что новая версия снова копируется в целевой каталог.
На заметку! Если вы используете Visual Studio, то можете щелкнуть на кнопке Show All Files (Показать все файлы) в окне Solution Explorer, что позволит увидеть все выходные файлы и удостовериться в наличии там скомпилированной библиотеки
CarLibrary
. Если вы работаете в Visual Studio Code, тогда перейдите в каталог bin\debug\net5.0
на вкладке Explorer (Проводник).
Когда создается прямая ссылка, скомпилированная библиотека тоже копируется в выходной каталог клиентской библиотеки, но во время создания ссылки. Без ссылки на проект сами проекты можно компилировать независимо друг от друга и файлы могут стать несогласованными. Выражаясь кратко, если вы разрабатываете зависимые библиотеки (как обычно происходит в реальных программных проектах), то лучше ссылаться на проект, а не на результат компиляции проекта.
Вспомните, что платформа .NET Core позволяет разработчикам разделять скомпилированный код между языками программирования. Чтобы проиллюстрировать языковую независимость платформы .NET Core, создайте еще один проект консольного приложения (по имени
VisualBasicCarClient
) на этот раз с применением языка Visual Basic (имейте в виду, что каждая команда вводится в отдельной строке):
dotnet new console -lang vb -n VisualBasicCarClient
-o .\VisualBasicCarClient -f net5.0
dotnet add VisualBasicCarClient reference CarLibrary
dotnet sln .\Chapter16_AllProjects.sln add VisualBasicCarClient
Подобно C# язык Visual Basic позволяет перечислять все пространства имен, используемые внутри текущего файла. Тем не менее, вместо ключевого слова
using
, применяемого в С#, для такой цели в Visual Basic служит ключевое слово Imports
, поэтому добавьте в файл кода Program.vb
следующий оператор Imports
:
Imports CarLibrary
Module Program
Sub Main()
End Sub
End Module
Обратите внимание, что метод
Main()
определен внутри типа модуля Visual Basic. По существу модули представляют собой систему обозначений Visual Basic для определения класса, который может содержать только статические методы (очень похоже на статический класс С#). Итак, чтобы испробовать типы MiniVan
и SportsCar
, используя синтаксис Visual Basic, модифицируйте метод Main()
, как показано ниже:
Sub Main()
Console.WriteLine("***** VB CarLibrary Client App *****")
' Локальные переменные объявляются с применением ключевого слова Dim.
Dim myMiniVan As New MiniVan()
myMiniVan.TurboBoost()
Dim mySportsCar As New SportsCar()
mySportsCar.TurboBoost()
Console.ReadLine()
End Sub
После компиляции и запуска приложения (не забудьте установить VisualBasic
CarClient
как стартовый проект в Visual Studio) снова отобразится последовательность окон с сообщениями. Кроме того, новое клиентское приложение имеет собственную локальную копию CarLibrary.dll
в своем каталоге bin\Debug\net5.0
.
Привлекательным аспектом разработки в .NET Core является понятие межъязыкового наследования. В целях иллюстрации давайте создадим новый класс Visual Basic, производный от типа
SportsCar
(который был написан на С#). Для начала добавьте в текущее приложение Visual Basic новый файл класса по имени PerformanceCar.vb
. Модифицируйте начальное определение класса, унаследовав его от типа SportsCar
с применением ключевого слова Inherits
. Затем переопределите абстрактный метод TurboBoost()
, используя ключевое слово Overrides
:
Imports CarLibrary
' Этот класс VB унаследован от класса SportsCar, написанного на C#.
Public Class PerformanceCar
Inherits SportsCar
Public Overrides Sub TurboBoost()
Console.WriteLine("Zero to 60 in a cool 4.8 seconds...")
End Sub
End Class
Чтобы протестировать новый тип класса, модифицируйте код метода
Main()
в модуле:
Sub Main()
...
Dim dreamCar As New PerformanceCar()
' Использовать унаследованное свойство.
dreamCar.PetName = "Hank"
dreamCar.TurboBoost()
Console.ReadLine()
End Sub
Обратите внимание, что объект
dreamCar
способен обращаться к любому открытому члену (такому как свойство PetName
), расположенному выше в цепочке наследования, невзирая на тот факт, что базовый класс был определен на совершенно другом языке и находится полностью в другой сборке! Возможность расширения классов за пределы границ сборок в независимой от языка манере — естественный аспект цикла разработки в .NET Core. Он упрощает применение скомпилированного кода, написанного программистами, которые предпочли не создавать свой разделяемый код на языке С#.
Как упоминалось ранее, внутренние (
internal
) классы видимы остальным объектам только в сборке, где они определены. Исключением является ситуация, когда видимость явно предоставляется другому проекту.
Начните с добавления в проект
CarLibrary
нового класса по имени MyInternalClass
со следующим кодом:
namespace CarLibrary
{
internal class MyInternalClass
{
}
}
На заметку! Зачем вообще открывать доступ к внутренним типам? Обычно это делается для модульного и интеграционного тестирования. Разработчики хотят иметь возможность тестировать свой код, но не обязательно открывать к нему доступ за границами сборки.
Атрибуты будут более детально раскрыты в главе 17, но пока откройте файл класса
Car.cs
из проекта CarLibrary
и добавьте показанный ниже атрибут и оператор using
:
using System.Runtime.CompilerServices;
[assembly:InternalsVisibleTo("CSharpCarClient")]
namespace CarLibrary
{
}
Атрибут
InternalsVisibleTo
принимает имя проекта, который может видеть класс с установленным атрибутом. Имейте в виду, что другие проекты не в состоянии "запрашивать" такое разрешение; оно должно быть предоставлено проектом, содержащим внутренние типы.
На заметку! В предшествующих версиях .NET использовался файл класса
AssemblyInfо.cs
, который по-прежнему существует в .NET Core, но генерируется автоматически и не предназначен для потребления разработчиками.
Теперь можете модифицировать проект
CSharpCarClient
, добавив в метод Main()
следующий код:
var internalClassInstance = new MyInternalClass();
Код работает нормально. Затем попробуйте сделать то же самое в методе
Main()
проекта VisualBasicCarClient
:
' Не скомпилируется
' Dim internalClassInstance = New MyInternalClass()
Поскольку библиотека
VisualBasicCarClient
не предоставила разрешение видеть внутренние типы, предыдущий код не скомпилируется.
Еще один способ добиться того же (и можно утверждать, что он в большей степени соответствует стилю .NET Core) предусматривает применение обновленных возможностей файла проекта .NET Core.
Закомментируйте только что добавленный атрибут и откройте файл проекта
CarLibrary
. Добавьте в файл проекта узел ItemGroup
, как показано ниже:
Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>CSharpCarClient
Результат оказывается таким же, как в случае использования атрибута в классе, и считается более удачным решением, потому что другие разработчики будут видеть его прямо в файле проекта, а не искать повсюду в проекте.
NuGet — это диспетчер пакетов для .NET и .NET Core. Он является механизмом для совместного использования программного обеспечения в формате, который воспринимается приложениями .NET Core, а также стандартным способом загрузки .NET Core и связанных инфраструктур (ASP.NET Core, EF Core и т.д.). Многие организации помещают в пакеты NuGet свои стандартные сборки, предназначенные для решения сквозных задач (наподобие ведения журнала и построения отчетов об ошибках), с целью потребления в разрабатываемых бизнес-приложениях.
Чтобы увидеть пакетирование в действии, понадобиться поместить библиотеку
CarLibrary
внутрь пакета и затем ссылаться на пакет из двух клиентских приложений.
Свойства пакета NuGet доступны через окно свойств проекта. Щелкните правой кнопкой мыши на имени проекта
CarLibrary
и выберите в контекстном меню пункт Properties (Свойства). Перейдя на вкладку Package (Пакет), вы увидите значения, которые вводились ранее для настройки сборки. Для пакета NuGet можно установить дополнительные свойства (скажем, принятие лицензионного соглашения и информацию о проекте, такую как URL и местоположение хранилища).
На заметку! Все значения на вкладке Package пользовательского интерфейса Visual Studio могут быть введены в файле проекта вручную, но вы должны знать ключевые слова. Имеет смысл хотя бы раз воспользоваться Visual Studio для ввода всех значений и затем вручную редактировать файл проекта. Кроме того, все допустимые свойства описаны в документации по .NET Core.
В текущем примере кроме флажка Generate NuGet package on build (Генерировать пакет NuGet при компиляции) никаких дополнительных свойств устанавливать не нужно. Можно также модифицировать файл проекта следующим образом:
net5.0
Copyright 2020
Phil Japikse
Apress
Pro C# 9.0
CarLibrary
This is an awesome library for cars.
1.0.0.1
1.0.0.2
1.0.0.3
true
Это приведет к тому, что пакет будет создаваться заново при каждой компиляции проекта. По умолчанию пакет создается в подкаталоге
bin\Debug
или bin\Release
в зависимости от выбранной конфигурации.
Пакеты также можно создавать в командной строке, причем интерфейс CLI предлагает больше параметров, чем среда Visual Studio. Например, чтобы построить пакет и поместить его в каталог по имени
Publish
, введите показанные далее команды (находясь в каталоге проекта CarLibrary
). Первая команда компилирует сборку, а вторая создает пакет NuGet.
dotnet build -c Release
dotnet pack -o .\Publish -c Debug
На заметку!
Debug
является стандартной конфигурацией и потому указывать -с Debug
необязательно, но параметр присутствует в команде, чтобы намерение стало совершенно ясным.
Теперь в каталоге
Publish
находится файл CarLibrary.1.0.0.3.nupkg
. Для просмотра его содержимого откройте файл с помощью любой утилиты zip-архивации (такой как 7-Zip). Вы увидите полное содержимое, которое включает сборку и дополнительные метаданные.
Вас может интересовать, откуда поступают пакеты, добавленные в предшествующих примерах. Местоположением пакетов NuGet управляет файл XML по имени
NuGet.Config
. В среде Windows он находится в каталоге %appdata%\NuGet
. Это главный файл. Открыв его, вы увидите несколько источников пакетов:
protocolVersion="3" />
value="C:\Program Files (x86)\
Microsoft SDKs\NuGetPackages\" />
Здесь присутствуют два источника пакетов. Первый источник указывает на
http://nuget.org/
— крупнейшее в мире хранилище пакетов NuGet. Второй источник находится на вашем локальном диске и применяется средой Visual Studio в качестве кеша пакетов.
Важно отметить, что файлы
NuGet.Config
по умолчанию являются аддитивными. Чтобы добавить дополнительные источники, не изменяя список источников для всей системы, вы можете создавать дополнительные файлы NuGet.Config
. Каждый файл действителен для каталога, в котором он находится, а также для любых имеющихся подкаталогов. Добавьте в каталог решения новый файл по имени NuGet.Config
со следующим содержимым:
Кроме того, вы можете очищать список источников пакетов, добавляя в узел
элемент
:
>
На заметку! В случае работы в Visual Studio вам придется перезапустить IDE-среду, чтобы обновленные настройки
NuGet.Config
вступили в силу.
Удалите ссылки на проекты из проектов
CSharpCarClient
и VisualBasicCarClient
, после чего добавьте ссылки на пакет (находясь в каталоге решения):
dotnet add CSharpCarClient package CarLibrary
dotnet add VisualBasicCarClient package CarLibrary
Установив ссылки, скомпилируйте решение и просмотрите целевой каталог (
bin\Debug\new5.0
). Вы увидите, что в целевом каталоге находится файл CarLibrary.dll
, а файл CarLibrary.nupkg
отсутствует. Причина в том, что исполняющая среда .NET Core распаковывает файл CarLibrary.nupkg
и добавляет содержащиеся в нем сборки как прямые ссылки.
Установите одного из клиентских проектов в качестве стартового и запустите приложение; оно будет функционировать точно так же, как ранее.
Смените номер версии библиотеки
CarLibrary
на 1.0.0.4 и снова создайте пакет. Теперь в каталоге Publish
присутствуют два NuGet-пакета CarLibrary
. Если вы опять выполните команды add package
, то проект обновится для использования новой версии. На тот случай, когда предпочтительнее более старая версия, команда add package
позволяет добавить номер версии для определенного пакета.
Итак, имея приложение
CarClient
на C# (и связанную с ним сборку CarLibrary
), каким образом вы собираетесь передавать его своим пользователям? Пакетирование приложения вместе с его зависимостями называется опубликованием. Опубликование приложений .NET Framework требовало, чтобы на целевой машине была установлена инфраструктура, и приложения .NET Core также могут быть опубликованы похожим способом, который называется развертыванием, зависящим от инфраструктуры. Однако приложения .NET Core вдобавок могут публиковаться как автономные, которые вообще не требуют наличия установленной платформы .NET Core! Когда приложение публикуется как автономное, вы обязаны указать идентификатор целевой исполняющей среды. Идентификатор исполняющей среды применяется для пакетирования вашего приложения, ориентированного на определенную ОС. Полный список доступных идентификаторов исполняющих сред приведен в каталоге .NET Core RID Catalog по ссылке https://docs.microsoft.com/ru-ru/dotnet/core/rid-catalog
.
На заметку! Опубликование приложений ASP. NET Core — более сложный процесс, который будет раскрыт позже в книге.
Развертывание, зависящее от инфраструктуры, представляет собой стандартный режим для команды
dotnet publish
. Чтобы создать пакет с вашим приложением и обязательными файлами, понадобится лишь выполнить следующую команду в интерфейсе командной строки:
dotnet publish
На заметку! Команда
publish
использует стандартную конфигурацию для вашего проекта, которой обычно является Debug
.
Приведенная выше команда помещает ваше приложение и поддерживающие его файлы (всего 16 файлов) в каталог
bin\Debug\net5.0\publish
. Заглянув в упомянутый каталог, вы обнаружите два файла *.dll
(CarLibrary.dll
и CSharpCarClient.dll
), которые содержат весь прикладной код. В качестве напоминания: файл CSharpCarClient.exe
представляет собой пакетированную версию dotnet.exe
, сконфигурированную для запуска CSharpCarClient.dll
. Дополнительные файлы в каталоге — это файлы .NET Core, которые не входят в состав .NET Core Runtime.
Чтобы создать версию
Release
(которая будет помещена в каталог bin\release\net5.0\publish
), введите такую команду:
dotnet publish -c release
Подобно развертыванию, зависящему от инфраструктуры, автономное развертывание включает весь прикладной код и сборки, на которые производилась ссылка, а также файлы .NET Core Runtime, требующиеся приложению. Чтобы опубликовать свое приложение как автономное развертывание, выполните следующую команду CLI (указывающую в качестве выходного местоположения каталог по имени
selfcontained
):
dotnet publish -r win-x64 -c release -o selfcontained --self-contained true
На заметку! При создании автономного развертывания обязателен идентификатор исполняющей среды, чтобы процессу опубликования было известно, какие файлы .NET Core Runtime добавлять к вашему прикладному коду.
Команда помещает ваше приложение и его поддерживающие файлы (всего 235 файлов) в каталог
selfcontained
. Если вы скопируете эти файлы на другой компьютер с 64-разрядной ОС Windows, то сможете запускать приложение, даже если исполняющая среда .NET 5 на нем не установлена.
В большинстве ситуаций развертывание 235 файлов (для приложения, которое выводит всего лишь несколько строк текста) вряд ли следует считать наиболее эффективным способом предоставления вашего приложения пользователям. К счастью, в .NET 5 значительно улучшена возможность опубликования вашего приложения и межплатформенных файлов исполняющей среды в виде единственного файла. Не включаются только файлы собственных библиотек, которые должны существовать вне одиночного файла ЕХЕ.
Показанная ниже команда создает однофайловое автономное развертывание для 64-разрядных ОС Windows и помещает результат в каталог по имени
singlefile
:
dotnet publish -r win-x64 -c release -o singlefile --self-contained
true -p:PublishSingleFile=true
Исследуя файлы, которые были созданы, вы обнаружите один исполняемый файл (
CSharpCarClient.exe
), отладочный файл (CSharpCarClient.pdb
) и четыре DLL-библиотеки, специфичные для ОС. В то время как предыдущий процесс опубликования производил 235 файлов, однофайловая версия CSharpCarClient.exe
имеет размер 54 Мбайт! Создание однофайлового развертывания упаковывает 235 файлов в единственный файл. За снижение количества файлов приходится платить увеличением размера файла.
Напоследок важно отметить, что собственные библиотеки тоже можно поместить в единственный файл. Модифицируйте файл
CSharpCarClient.csproj
следующим образом:
Exe
net5.0
true
После запуска приведенной выше команды
dotnet publish
на выходе окажется одиночный файл. Тем не менее, это только механизм транспортировки. При запуске приложения файлы собственных библиотек будут извлечены во временное местоположение на целевой машине.
Все сборки, построенные до сих пор, были связаны напрямую (кроме только что законченного примера с пакетом NuGet). Вы добавляли либо ссылку на проект, либо прямую ссылку между проектами. В таких случаях (и в примере с NuGet) зависимая сборка копировалась напрямую в целевой каталог клиентского приложения. Определение местонахождения зависимой сборки не является проблемой, т.к. она размещается на диске рядом с приложением, которое в ней нуждается.
Но что насчет инфраструктуры .NET Core? Как ищутся ее сборки? В предшествующих версиях .NET файлы инфраструктуры устанавливались в глобальный кеш сборок (GAC), так что всем приложениям . NET было известно, где их найти.
Однако GAC мешает реализовать возможности параллельного выполнения разных версий приложений в .NET Core, поэтому здесь нет одиночного хранилища для файлов исполняющей среды и инфраструктуры. Взамен все файлы, составляющие инфраструктуру, устанавливаются в каталог
C:\Program Files\dotnet
(в среде Windows) с разделением по версиям. В зависимости от версии приложения (как указано в файле .csproj
) необходимые файлы исполняющей среды и инфраструктуры загружаются из каталога заданной версии.
В частности, при запуске какой-то версии исполняющей среды предоставляется набор путей зондирования, которые будут применяться для нахождения зависимостей приложения. Существуют пять свойств зондирования, перечисленные в табл. 16.1 (все они необязательны).
Чтобы выяснить стандартные пути зондирования, создайте новый проект консольного приложения .NET Core по имени
FunWithProbingPaths
. Приведите операторы верхнего уровня к следующему виду:
using System;
using System.Linq;
Console.WriteLine("*** Fun with Probing Paths ***");
Console.WriteLine($"TRUSTED_PLATFORM_ASSEMBLIES: ");
//Use ':' on non-Windows platforms
var list = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")
.ToString().Split(';');
foreach (var dir in list)
{
Console.WriteLine(dir);
}
Console.WriteLine();
Console.WriteLine($"PLATFORM_RESOURCE_ROOTS:
{AppContext.GetData ("PLATFORM_RESOURCE_
ROOTS")}");
Console.WriteLine();
Console.WriteLine($"NATIVE_DLL_SEARCH_DIRECTORIES:
{AppContext.GetData ("NATIVE_DLL_SEARCH_
DIRECTORIES")}");
Console.WriteLine();
Console.WriteLine($"APP_PATHS: {AppContext.GetData("APP_PATHS")}");
Console.WriteLine();
Console.WriteLine($"APP_NI_PATHS: {AppContext.GetData("APP_NI_PATHS")}");
Console.WriteLine();
Console.ReadLine();
Запустив приложение, вы увидите большинство значений, поступающих из переменной
TRUSTED_PLATFORM_ASSEMBLIES
. В дополнение к сборке, созданной для этого проекта в целевом каталоге, будет выведен список библиотек базовых классов из каталога текущей исполняющей среды, C:\Program Files\dotnet\shared\Microsoft.NETCore.Арр\5.0.0
(номер версии у вас может быть другим).
В список добавляется каждый файл, на который напрямую ссылается ваше приложение, а также любые файлы исполняющей среды, требующиеся вашему приложению. Список библиотек исполняющей среды заполняется одним или большим числом файлов
*.deps.json
, которые загружаются вместе с исполняющей средой .NET Core. Они находятся в каталоге установки для комплекта SDK (применяется при построении программ) и исполняющей среды (используется при выполнении программ). В рассматриваемом простом примере задействован только один файл такого рода — Microsoft.NETCore.Арр.deps.json
.
По мере возрастания сложности вашего приложения будет расти и список файлов в
TRUSTED_PLATFORM_ASSEMBLIES
. Скажем, если вы добавите ссылку на пакет Microsoft.EntityFrameworkCore
, то список требующихся сборок расширится. Чтобы удостовериться в этом, введите показанную ниже команду в консоли диспетчера пакетов (в каталоге, где располагается файл *.csproj
):
dotnet add package Microsoft.EntityFrameworkCore
После добавления пакета снова запустите приложение и обратите внимание, насколько больше стало файлов в списке. Хотя вы добавили только одну новую ссылку, пакет
Microsoft.EntityFrameworkCore
имеет собственные зависимости, которые добавляются в TRUSTED_PLATFORM_ASSEMBLIES
.
В главе была исследована роль библиотек классов .NET Core (файлов
*.dll
). Вы видели, что библиотеки классов представляют собой двоичные файлы .NET Core, содержащие логику, которая предназначена для многократного использования в разнообразных проектах.
Вы ознакомились с деталями разнесения типов по пространствам имен .NET Core и отличием между .NET Core и .NET Standard, приступили к конфигурированию приложений и углубились в состав библиотек классов. Затем вы научились публиковать консольные приложения .NET Core. В заключение вы узнали, каким образом пакетировать свои приложения с применением NuGet.
Как было показано в главе 16, сборки являются базовой единицей развертывания в мире .NET Core. Используя интегрированный браузер объектов Visual Studio (и многих других IDE-сред), можно просматривать типы внутри набора сборок, на которые ссылается проект. Кроме того, внешние инструменты, такие как утилита
ildasm.exe
, позволяют заглядывать внутрь лежащего в основе кода CIL, метаданных типов и манифеста сборки для заданного двоичного файла .NET Core. В дополнение к подобному исследованию сборок .NET Core на этапе проектирования ту же самую информацию можно получить программно с применением пространства имен System.Reflection
. Таким образом, первой задачей настоящей главы является определение роли рефлексии и потребности в метаданных .NET Core.
Остаток главы посвящен нескольким тесно связанным темам, которые вращаются вокруг служб рефлексии. Например, вы узнаете, как клиент .NET Core может задействовать динамическую загрузку и позднее связывание для активизации типов, сведения о которых на этапе компиляции отсутствуют. Вы также научитесь вставлять специальные метаданные в сборки .NET Core за счет использования системных и специальных атрибутов. Для практической демонстрации всех этих аспектов в завершение главы приводится пример построения нескольких "объектов-оснасток", которые можно подключать к расширяемому консольному приложению.
Возможность полного описания типов (классов, интерфейсов, структур, перечислений и делегатов) с помощью метаданных является ключевым элементом платформы .NET Core. Многим технологиям .NET Core, таким как сериализация объектов, требуется способность выяснения формата типов во время выполнения. Кроме того, межъязыковое взаимодействие, многие службы компилятора и средства IntelliSense в IDE-среде опираются на конкретное описание типа.
Вспомните, что утилита
ildasm.exe
позволяет просматривать метаданные типов в сборке. Чтобы взглянуть на метаданные сборки CarLibrary
, перейдите к разделу METAINFO
в сгенерированном файле CarLibrary.il
(из главы 16). Ниже приведен небольшой их фрагмент:
// ==== M E T A I N F O ===
// ===========================================================
// ScopeName : CarLibrary.dll
// MVID : {598BC2B8-19E9-46EF-B8DA-672A9E99B603}
// ===========================================================
// Global functions
// -------------------------------------------------------
//
// Global fields
// -------------------------------------------------------
//
// Global MemberRefs
// -------------------------------------------------------
//
// TypeDef #1
// -------------------------------------------------------
// TypDefName: CarLibrary.Car
// Flags : [Public] [AutoLayout] [Class] [Abstract] [AnsiClass] [BeforeFieldInit]
// Extends : [TypeRef] System.Object
// Field #1
// -------------------------------------------------------
// Field Name: value__
// Flags : [Private]
// CallCnvntn: [FIELD]
// Field type: String
//
Как видите, утилита
ildasm.exe
отображает метаданные типов .NET Core очень подробно (фактический двоичный формат гораздо компактнее). В действительности описание всех метаданных сборки CarLibrary.dll
заняло бы несколько страниц. Однако для понимания вполне достаточно кратко взглянуть на некоторые ключевые описания метаданных сборки CarLibrary.dll
.
На заметку! Не стоит слишком глубоко вникать в синтаксис каждого фрагмента метаданных .NET Core, приводимого в нескольких последующих разделах. Важно усвоить, что метаданные .NET Core являются исключительно описательными и учитывают каждый внутренне определенный (и внешне ссылаемый) тип, который найден в имеющейся кодовой базе.
Каждый тип, определенный внутри текущей сборки, документируется с применением маркера
TypeDef #n
(где TypeDef
— сокращение от type definition (определение типа)). Если описываемый тип использует какой-то тип, определенный в отдельной сборке .NET Core, тогда ссылаемый тип документируется с помощью маркера TypeRef #n
(где TypeRef
— сокращение от type reference (ссылка на тип)). Если хотите, то можете считать, что маркер TypeRef
является указателем на полное определение метаданных ссылаемого типа во внешней сборке. Коротко говоря, метаданные .NET Core — это набор таблиц, явно помечающих все определения типов (TypeDef
) и ссылаемые типы (TypeRef
), которые могут быть просмотрены с помощью утилиты ildasm.exe
.
В случае сборки
CarLibrary.dll
один из маркеров TypeDef
представляет описание метаданных перечисления CarLibrary.EngineStateEnum
(номер TypeDef
у вас может отличаться; нумерация TypeDef
основана на порядке, в котором компилятор C# обрабатывает файл):
// TypeDef #2
// -------------------------------------------------------
// TypDefName: CarLibrary.EngineStateEnum
// Flags : [Public] [AutoLayout] [Class] [Sealed] [AnsiClass]
// Extends : [TypeRef] System.Enum
// Field #1
// -------------------------------------------------------
// Field Name: value__
// Flags : [Public] [SpecialName] [RTSpecialName]
// CallCnvntn: [FIELD]
// Field type: I4
//
// Field #2
// -------------------------------------------------------
// Field Name: EngineAlive
// Flags : [Public] [Static] [Literal] [HasDefault]
// DefltValue: (I4) 0
// CallCnvntn: [FIELD]
// Field type: ValueClass CarLibrary.EngineStateEnum
//
...
Маркер
TypDefName
служит для установления имени заданного типа, которым в рассматриваемом случае является специальное перечисление CarLibrary.EngineStateEnum
. Маркер метаданных Extends
применяется при документировании базового типа для заданного типа .NET Core (ссылаемого типа System.Enum
в этом случае). Каждое поле перечисления помечается с использованием маркера Field #n
. Ради краткости выше была приведена только часть метаданных.
На заметку! Хотя это выглядит как опечатка, в
TypDefName
отсутствует буква "е
", которую можно было бы ожидать.
Ниже показана часть метаданных класса
Car
, которая иллюстрирует следующие аспекты:
• как поля определяются в терминах метаданных .NET Core;
• как методы документируются посредством метаданных .NET Core;
• как автоматическое свойство представляется в метаданных .NET Core.
// TypeDef #1
// -------------------------------------------------------
// TypDefName: CarLibrary.Car
// Flags : [Public] [AutoLayout] [Class] [Abstract] [AnsiClass] [BeforeFieldInit]
// Extends : [TypeRef] System.Object
// Field #1
// -------------------------------------------------------
// Field Name: k__BackingField
// Flags : [Private]
// CallCnvntn: [FIELD]
// Field type: String
...
Method #1
-------------------------------------------------------
MethodName: get_PetName
Flags : [Public] [HideBySig] [ReuseSlot] [SpecialName]
RVA : 0x000020d0
ImplFlags : [IL] [Managed]
CallCnvntn: [DEFAULT]
hasThis
ReturnType: String
No arguments.
...
// Method #2
// -------------------------------------------------------
// MethodName: set_PetName
// Flags : [Public] [HideBySig] [ReuseSlot] [SpecialName]
// RVA : 0x00002058
// ImplFlags : [IL] [Managed]
// CallCnvntn: [DEFAULT]
// hasThis
// ReturnType: Void
// 1 Arguments
// Argument #1: String
// 1 Parameters
// (1) ParamToken : Name : value flags: [none]
...
// Property #1
// -------------------------------------------------------
// Prop.Name : PetName
// Flags : [none]
// CallCnvntn: [PROPERTY]
// hasThis
// ReturnType: String
// No arguments.
// DefltValue:
// Setter : set_PetName
// Getter : get_PetName
// 0 Others
...
Прежде всего, метаданные класса
Car
указывают базовый класс этого типа (System.Object
) и включают разнообразные флаги, которые описывают то, как тип был сконструирован (например, [Public]
, [Abstract]
и т.п.). Описания методов (вроде конструктора Car
) содержат имя, возвращаемое значение и параметры.
Обратите внимание, что автоматическое свойство дает в результате сгенерированное компилятором закрытое поддерживающее поле (по имени
k_BackingField
) и два сгенерированных компилятором метода (в случае свойства для чтения и записи) с именами get_PetName()
и set_PetName()
. Наконец, само свойство отображается на внутренние методы получения/установки с применением маркеров Setter
и Getter
метаданных .NET Core.
Вспомните, что метаданные сборки будут описывать не только набор внутренних типов (
Car
, EnginestateEnum
и т.д.), но также любые внешние типы, на которые ссылаются внутренние типы. Например, с учетом того, что в сборке CarLibrary.dll
определены два перечисления, метаданные типа System.Enum
будут содержать следующий блок TypeRef
:
// TypeRef #19
// -------------------------------------------------------
// Token: 0x01000013
// ResolutionScope: 0x23000001
// TypeRefName: System.Enum
В файле
CarLibrary.il
также присутствуют метаданные .NET Core, которые описывают саму сборку с использованием маркера Assembly
. Ниже представлена часть метаданных манифеста сборки CarLibrary.dll
:
// Assembly
// -------------------------------------------------------
// Token: 0x20000001
// Name : CarLibrary
// Public Key :
// Hash Algorithm : 0x00008004
// Version: 1.0.0.1
// Major Version: 0x00000001
// Minor Version: 0x00000000
// Build Number: 0x00000000
// Revision Number: 0x00000001
// Locale:
// Flags : [none] (00000000)
В дополнение к маркеру
Assembly
и набору блоков TypeDef
и TypeRef
в метаданных .NET Core также применяются маркеры AssemblyRef #n
для документирования каждой внешней сборки. С учетом того, что каждая сборка .NET Core ссылается на библиотеку базовых классов System.Runtime
, вы обнаружите AssemblyRef
для сборки System.Runtime
, как показано в следующем фрагменте:
// AssemblyRef #1 (23000001)
// -------------------------------------------------------
// Token: 0x23000001
// Public Key or Token: b0 3f 5f 7f 11 d5 0a 3a
// Name: System.Runtime
// Version: 5.0.0.0
// Major Version: 0x00000005
// Minor Version: 0x00000000
// Build Number: 0x00000000
// Revision Number: 0x00000000
// Locale:
// HashValue Blob:
// Flags: [none] (00000000)
Последний полезный аспект, относящийся к метаданным .NET Core, связан с тем, что все строковые литералы в кодовой базе документируются внутри маркера
User Strings
:
// User Strings
// -------------------------------------------------------
// 70000001 : (23) L"CarLibrary Version 2.0!"
// 70000031 : (13) L"Quiet time..."
// 7000004d : (11) L"Jamming {0}"
// 70000065 : (32) L"Eek! Your engine block exploded!"
// 700000a7 : (34) L"Ramming speed! Faster is better..."
На заметку! Всегда помните о том, что все строки явным образом документируются в метаданных сборки, как продемонстрировано в представленном выше листинге метаданных. Это может привести к крупным последствиям в плане безопасности, если вы применяете строковые литералы для хранения паролей, номеров кредитных карт или другой конфиденциальной информации.
У вас может возникнуть вопрос о том, каким образом задействовать такую информацию в разрабатываемых приложениях (в лучшем сценарии) или зачем вообще заботиться о метаданных (в худшем сценарии). Чтобы получить ответ, необходимо ознакомиться со службами рефлексии .NET Core. Следует отметить, что полезность рассматриваемых далее тем может стать ясной только ближе к концу главы, а потому наберитесь терпения.
На заметку! В разделе
METAINFO
вы также найдете несколько маркеров CustomAttribute
, которые документируют атрибуты, применяемые внутри кодовой базы. Роль атрибутов .NET Core обсуждается позже в главе.
В мире .NET Core рефлексией называется процесс обнаружения типов во время выполнения. Службы рефлексии дают возможность получать программно ту же самую информацию о метаданных, которую генерирует утилита
ildasm.exe
, используя дружественную объектную модель. Например, посредством рефлексии можно извлечь список всех типов, содержащихся внутри заданной сборки *.dll
или *.ехе
, в том числе методы, поля, свойства и события, которые определены конкретным типом. Можно также динамически получать набор интерфейсов, поддерживаемых заданным типом, параметры метода и другие относящиеся к ним детали (базовые классы, пространства имен, данные манифеста и т.д.).
Как и любое другое пространство имен,
System.Reflection
(из сборки System.Runtime.dll
) содержит набор связанных типов. В табл. 17.1 описаны основные члены System.Reflection
, которые необходимо знать.
Чтобы понять, каким образом задействовать пространство имен
System.Reflection
для программного чтения метаданных .NET Core, сначала следует ознакомиться с классом System.Туре
.
В классе
System.Туре
определены члены, которые могут применяться для исследования метаданных типа, большое количество которых возвращают типы из пространства имен System.Reflection
. Например, метод Туре.GetMethods()
возвращает массив объектов MethodInfo
, метод Type.GetFields()
— массив объектов FieldInfo
и т.д. Полный перечень членов, доступных в System.Туре
, довольно велик, но в табл. 17.2 приведен список избранных членов, поддерживаемых System.Туре
(за исчерпывающими сведениями обращайтесь в документацию по .NET Core).
Экземпляр класса
Туре
можно получать разнообразными способами. Тем не менее, есть одна вещь, которую делать невозможно — создавать объект Туре
напрямую, используя ключевое слово new
, т.к. Туре
является абстрактным классом. Касательно первого способа вспомните, что в классе System.Object
определен метод GetType()
, который возвращает экземпляр класса Туре
, представляющий метаданные текущего объекта:
// Получить информацию о типе с применением экземпляра SportsCar.
SportsCar sc = new SportsCar();
Type t = sc.GetType();
Очевидно, что такой подход будет работать, только если подвергаемый рефлексии тип (
SportsCar
в данном случае) известен на этапе компиляции и в памяти присутствует его экземпляр. С учетом этого ограничения должно быть понятно, почему инструменты вроде ildasm.exe
не получают информацию о типе, непосредственно вызывая метод System.Object.GetType()
для каждого типа — ведь утилита ildasm.exe
не компилировалась вместе с вашими специальными сборками.
Следующий способ получения информации о типе предполагает применение операции
typeof
:
// Получить информацию о типе с использованием операции typeof.
Type t = typeof(SportsCar);
В отличие от метода
System.Object.GetType()
операция typeof
удобна тем, что она не требует предварительного создания экземпляра объекта перед получением информации о типе. Однако кодовой базе по-прежнему должно быть известно об исследуемом типе на этапе компиляции, поскольку typeof
ожидает получения строго типизированного имени типа.
Для получения информации о типе в более гибкой манере можно вызывать статический метод
GetType()
класса System.Туре
и указывать полностью заданное строковое имя типа, который планируется изучить. При таком подходе знать тип, из которого будут извлекаться метаданные, на этапе компиляции не нужно, т.к. метод Type.GetType()
принимает в качестве параметра экземпляр вездесущего класса System.String
.
На заметку! Когда речь идет о том, что при вызове метода
Туре.GetType()
знание типа на этапе компиляции не требуется, имеется в виду тот факт, что данный метод может принимать любое строковое значение (а не строго типизированную переменную). Разумеется, знать имя типа в строковом формате по-прежнему необходимо!
Метод
Туре.GetType()
перегружен, позволяя указывать два булевских параметра, из которых один управляет тем, должно ли генерироваться исключение, если тип не удается найти, а второй отвечает за то, должен ли учитываться регистр символов в строке. В целях иллюстрации рассмотрим следующий код:
// Получить информацию о типе с использованием статического
// метода Туре.GetType().
// (Не генерировать исключение, если тип SportsCar не удается найти,
// и игнорировать регистр символов.)
Type t = Type.GetType("CarLibrary.SportsCar", false, true);
В приведенном выше примере обратите внимание на то, что в строке, передаваемой методу
GetType()
, никак не упоминается сборка, внутри которой содержится интересующий тип. В этом случае делается предположение о том, что тип определен внутри сборки, выполняющейся в текущий момент. Тем не менее, когда необходимо получить метаданные для типа из внешней сборки, строковый параметр форматируется с использованием полностью заданного имени типа, за которым следует запятая и дружественное имя сборки (имя сборки без информации о версии), содержащей интересующий тип:
// Получить информацию о типе из внешней сборки.
Type t = Type.GetType("CarLibrary.SportsCar, CarLibrary");
Кроме того, в передаваемой методу
GetType()
строке может быть указан символ "плюс" (+
) для обозначения вложенного типа. Пусть необходимо получить информацию о типе перечисления (SpyOptions
), вложенного в класс по имени JamesBondCar
. В таком случае можно написать следующий код:
// Получить информацию о типе для вложенного перечисления
// внутри текущей сборки.
Type t = Type.GetType("CarLibrary.JamesBondCar+SpyOptions");
Чтобы ознакомиться с базовым процессом рефлексии (и выяснить полезность класса
System.Туре
), создайте новый проект консольного приложения по имени MyTypeViewer
. Приложение будет отображать детали методов, свойств, полей и поддерживаемых интерфейсов (в дополнение к другим интересным данным) для любого типа внутри System.Runtime.dll
(вспомните, что все приложения .NET Core автоматически получают доступ к этой основной библиотеке классов платформы) или типа внутри самого приложения MyTypeViewer
. После создания приложения не забудьте импортировать пространства имен System
, System.Reflection
и System.Linq
:
// Эти пространства имен должны импортироваться для выполнения
// любой рефлексии!
using System;
using System.Linq;
using System.Reflection;
В класс
Program
будут добавлены статические методы, каждый из которых принимает единственный параметр System.Туре
и возвращает void
. Первым делом определите метод ListMethods()
, который выводит имена методов, определенных во входном типе. Обратите внимание, что Туре.GetMethods()
возвращает массив объектов System.Reflection.MethodInfo
, по которому можно осуществлять проход с помощью стандартного цикла foreach
:
// Отобразить имена методов в типе.
static void ListMethods(Type t)
{
Console.WriteLine("***** Methods *****");
MethodInfo[] mi = t.GetMethods();
foreach(MethodInfo m in mi)
{
Console.WriteLine("->{0}", m.Name);
}
Console.WriteLine();
}
Здесь просто выводится имя метода с применением свойства
MethodInfo.Name
. Как не трудно догадаться, класс MethodInfo
имеет множество дополнительных членов, которые позволяют выяснить, является ли метод статическим, виртуальным, обобщенным или абстрактным. Вдобавок тип MethodInfo
дает возможность получить информацию о возвращаемом значении и наборе параметров метода. Чуть позже реализация ListMethods()
будет немного улучшена.
При желании для перечисления имен методов можно было бы также построить подходящий запрос LINQ. Вспомните из главы 13, что технология LINQ to Object позволяет создавать строго типизированные запросы и применять их к коллекциям объектов в памяти. В качестве эмпирического правила запомните, что при обнаружении блоков с программной логикой циклов или принятия решений можно использовать соответствующий запрос LINQ. Скажем, предыдущий метод можно было бы переписать так, задействовав LINQ:
using System.Linq;
static void ListMethods(Type t)
{
Console.WriteLine("***** Methods *****");
var methodNames = from n in t.GetMethods() select n.Name;
foreach (var name in methodNames)
{
Console.WriteLine("->{0}", name);
}
Console.WriteLine();
}
Реализация метода
ListFields()
похожа. Единственным заметным отличием является вызов Туре
. GetFields()
и результирующий массив элементов FieldInfо
. И снова для простоты выводятся только имена каждого поля с применением запроса LINQ:
// Отобразить имена полей в типе.
static void ListFields(Type t)
{
Console.WriteLine("***** Fields *****");
var fieldNames = from f in t.GetFields() select f.Name;
foreach (var name in fieldNames)
{
Console.WriteLine("->{0}", name);
}
Console.WriteLine();
}
Логика для отображения имен свойств типа аналогична:
// Отобразить имена свойств в типе.
static void ListProps(Type t)
{
Console.WriteLine("***** Properties *****");
var propNames = from p in t.GetProperties() select p.Name;
foreach (var name in propNames)
{
Console.WriteLine("->{0}", name);
}
Console.WriteLine();
}
Следующим создается метод по имени
ListInterfaces()
который будет выводить имена любых интерфейсов, поддерживаемых входным типом. Один интересный момент здесь заключается в том, что вызов GetInterfaces()
возвращает массив объектов System.Туре
! Это вполне логично, поскольку интерфейсы действительно являются типами:
// Отобразить имена интерфейсов, которые реализует тип.
static void ListInterfaces(Type t)
{
Console.WriteLine("***** Interfaces *****");
var ifaces = from i in t.GetInterfaces() select i;
foreach(Type i in ifaces)
{
Console.WriteLine("->{0}", i.Name);
}
}
На заметку! Имейте в виду, что большинство методов "получения" в
System.Туре
(GetMethods()
, GetInterfaces()
и т.д.) перегружены, чтобы позволить указывать значения из перечисления BindingFlags
. В итоге появляется высокий уровень контроля над тем, что в точности необходимо искать (например, только статические члены, только открытые члены, включать закрытые члены и т.д.). За более подробной информацией обращайтесь в документацию.
В качестве последнего, но не менее важного действия, осталось реализовать финальный вспомогательный метод, который будет отображать различные статистические данные о входном типе (является ли он обобщенным, какой его базовый класс, запечатан ли он и т.п.):
// Просто ради полноты картины.
static void ListVariousStats(Type t)
{
Console.WriteLine("***** Various Statistics *****");
Console.WriteLine("Base class is: {0}", t.BaseType); // Базовый класс
Console.WriteLine("Is type abstract? {0}", t.IsAbstract); // Абстрактный?
Console.WriteLine("Is type sealed? {0}", t.IsSealed); // Запечатанный?
Console.WriteLine("Is type generic? {0}", t.IsGenericTypeDefinition); // Обобщенный?
Console.WriteLine("Is type a class type? {0}", t.IsClass); // Тип класса?
Console.WriteLine();
}
Операторы верхнего уровня в файле
Program.cs
запрашивают у пользователя полностью заданное имя типа. После получения этих строковых данных они передаются методу Туре.GetType()
, а результирующий объект System.Туре
отправляется каждому вспомогательному методу. Процесс повторяется до тех пор, пока пользователь не введет Q для прекращения работы приложения.
Console.WriteLine("***** Welcome to MyTypeViewer *****");
string typeName = "";
do
{
Console.WriteLine("\nEnter a type name to evaluate");
// Пригласить ввести имя типа.
Console.Write("or enter Q to quit: "); // или Q для завершения
// Получить имя типа
typeName = Console.ReadLine();
// Пользователь желает завершить программу?
if (typeName.Equals("Q",StringComparison.OrdinalIgnoreCase))
{
break;
}
// Попробовать отобразить информацию о типе.
try
{
Type t = Type.GetType(typeName);
Console.WriteLine("");
ListVariousStats(t);
ListFields(t);
ListProps(t);
ListMethods(t);
ListInterfaces(t);
}
catch
{
Console.WriteLine("Sorry, can't find type");
}
} while (true);
В настоящий момент приложение
MyTypeViewer.exe
готово к тестовому запуску. Запустите его и введите следующие полностью заданные имена (не забывая, что Туре.GetType()
требует строковых имен с учетом регистра):
•
System.Int32
•
System.Collections.ArrayList
•
System.Threading.Thread
•
System.Void
•
System.10.BinaryWriter
•
System.Math
•
MyTypeViewer.Program
Ниже показан частичный вывод при указании
System.Math
:
***** Welcome to MyTypeViewer *****
Enter a type name to evaluate
or enter Q to quit: System.Math
***** Various Statistics *****
Base class is: System.Object
Is type abstract? True
Is type sealed? True
Is type generic? False
Is type a class type? True
***** Fields *****
->PI
->E
***** Properties *****
***** Methods *****
->Acos
->Asin
->Atan
->Atan2
->Ceiling
->Cos
...
Если вы введете
System.Console
для предыдущего метода, тогда в первом вспомогательном методе сгенерируется исключение, потому что значением t
будет null
. Статические типы не могут загружаться с помощью метода Туре.GetType(typeName)
. Взамен придется использовать другой механизм — функцию typeof
из System.Туре
. Модифицируйте программу для обработки особого случая System.Console
:
Type t = Type.GetType(typeName);
if (t == null && typeName.Equals("System.Console",
StringComparison.OrdinalIgnoreCase))
{
t = typeof(System.Console);
}
При вызове
Type.GetType()
для получения описаний метаданных обобщенных типов должен использоваться специальный синтаксис, включающий символ обратной одинарной кавычки ('
), за которым следует числовое значение, представляющее количество поддерживаемых параметров типа. Например, чтобы вывести описание метаданных System.Collections.Generic.List
, приложению потребуется передать следующую строку:
System.Collections.Generic.List`1
Здесь указано числовое значение
1
, т.к. List
имеет только один параметр типа. Однако для применения рефлексии к типу Dictionary
понадобится предоставить значение 2
:
System.Collections.Generic.Dictionary`2
Пока все хорошо! Далее мы внесем небольшое усовершенствование в текущее приложение. В частности, вы обновите вспомогательную функцию
ListMethods()
, чтобы перечислить не только имя данного метода, но и возвращаемый тип и типы входящих параметров. Тип MethodInfo
предоставляет свойство ReturnType
и метод GetParameters()
для выполнения этих задач. В следующем измененном коде обратите внимание, что вы создаете строку, которая содержит тип и имя каждого параметра с помощью вложенного цикла foreach
(без использования LINQ):
static void ListMethods(Type t)
{
Console.WriteLine("***** Methods *****");
MethodInfo[] mi = t.GetMethods();
foreach (MethodInfo m in mi)
{
// Получить возвращаемый тип.
string retVal = m.ReturnType.FullName;
string paramInfo = "( ";
// Получить параметры.
foreach (ParameterInfo pi in m.GetParameters())
{
paramInfo += string.Format("{0} {1} ", pi.ParameterType, pi.Name);
}
paramInfo += " )";
Теперь выведите на экран базовый метод sig.
Console.WriteLine("->{0} {1} {2}", retVal, m.Name, paramInfo);
}
Console.WriteLine();
}
Если вы запустите это обновленное приложение, вы обнаружите, что методы данного типа стали гораздо более подробными. Если вы введете в программу в качестве входных данных вашего доброго друга
System.Object
, то следующие методы будут отображать:
***** Methods *****
->System.Type GetType ( )
->System.String ToString ( )
->System.Boolean Equals ( System.Object obj )
->System.Boolean Equals ( System.Object objA System.Object objB )
->System.Boolean ReferenceEquals ( System.Object objA System.Object objB )
->System.Int32 GetHashCode ( )
Текущая реализация
ListMethods()
полезна тем, что вы можете напрямую исследовать каждый параметр и тип возврата метода, используя объектную модель System.Reflection
. В качестве крайнего сокращения, имейте в виду, что все типы XXXInfo
(MethodInfo
, PropertyInfo
, EventInfo
и т.д.) переопределили функцию ToString()
для отображения сигнатуры запрашиваемого элемента. Таким образом, вы также можете реализовать ListMethods()
следующим образом (снова используя LINQ, где вы просто выбираете все объекты MethodInfo
, а не только значения Name
значения):
static void ListMethods(Type t)
{
Console.WriteLine("***** Methods *****");
var methodNames = from n in t.GetMethods() select n;
foreach (var name in methodNames)
{
Console.WriteLine("->{0}", name);
}
Console.WriteLine();
}
Интересный материал, да? Очевидно, что пространство имен
System.Reflection
и класс System.Type
позволяют вам отражать многие другие аспекты типа, помимо того, что MyTypeViewer
отображает в данный момент. Как вы и ожидали можно получить события типа, список всех общих параметров для данного члена и другие подробности. десятки других деталей.
Тем не менее, на данном этапе вы создали (в некоторой степени способный) браузер объектов. Основное ограничение в этом конкретном примере заключается в том, что у вас нет возможности отразить не только текущую сборку (
MyTypeViewer
) или сборки в библиотеках базовых классов, на которые всегда есть ссылки, например mscorlib.dll
. В связи с этим возникает вопрос: "Как я могу создавать приложения, которые могут загружать (и отражать поверх) сборки, на которые нет ссылок во время компиляции? во время компиляции?" Рад, что вы спросили.
Бывают случаи, когда вам нужно программно загрузить сборки на лету, даже если нет записи о данной сборке в манифесте. Формально говоря, акт загрузки внешних сборок по требованию называется динамической загрузкой.
System.Reflection
определяет класс под названием Assembly
. Используя этот класс, вы можете динамически загружать сборку, а также обнаружить свойства самой сборки. Используя тип Assembly
, вы можете динамически загружать сборки, а также загружать сборку, расположенную в произвольном месте. По сути, класс Assembly
предоставляет методы, позволяющие программно загружать сборки с диска.
Чтобы проиллюстрировать динамическую загрузку, создайте новый проект консольного приложения с именем
ExternalAssemblyReflector
. Ваша задача ― создать код, который запрашивает имя сборки (минус расширения) для динамической загрузки. Вы передадите ссылку на сборку в вспомогательный метод под названием DisplayTypes()
, который просто выведет имена каждого класса, интерфейса, структуры, перечисления и делегата. делегата, который он содержит. Код освежающе прост.
using System;
using System.Reflection;
using System.IO; // Для определения FileNotFoundException.
Console.WriteLine("***** External Assembly Viewer *****");
string asmName = "";
Assembly asm = null;
do
{
Console.WriteLine("\nEnter an assembly to evaluate");
// Пригласить ввести имя сборки.
Console.Write("or enter Q to quit: "); // или Q для завершения
// Получить имя сборки.
asmName = Console.ReadLine();
// Пользователь желает завершить программу?
if (asmName.Equals("Q",StringComparison.OrdinalIgnoreCase))
{
break;
}
// Попробовать загрузить сборку.
try
{
asm = Assembly.LoadFrom(asmName);
DisplayTypesInAsm(asm);
}
catch
{
Console.WriteLine("Sorry, can't find assembly.");
// Сборка не найдена.
}
} while (true);
static void DisplayTypesInAsm(Assembly asm)
{
Console.WriteLine("\n***** Types in Assembly *****");
Console.WriteLine("->{0}", asm.FullName);
Type[] types = asm.GetTypes();
foreach (Type t in types)
{
Console.WriteLine("Type: {0}", t);
}
Console.WriteLine("");
}
Если вы хотите проводить рефлексию по
CarLibrary.dll
, тогда перед запуском приложения ExternalAssemblyReflector
понадобится скопировать двоичный файл CarLibrary.dll
(из предыдущей главы ) в каталог проекта (в случае применения Visual Studio Code) или в каталог \bin\Debug\net5.0
самого приложения (в случае использования Visual Studio). После выдачи запроса введите CarLibrary
(расширение необязательно); вывод будет выглядеть примерно так:
***** External Assembly Viewer *****
Enter an assembly to evaluate
or enter Q to quit: CarLibrary
***** Types in Assembly *****
->CarLibrary, Version=1.0.0.1, Culture=neutral, PublicKeyToken=null
Type: CarLibrary.MyInternalClass
Type: CarLibrary.EngineStateEnum
Type: CarLibrary.MusicMedia
Type: CarLibrary.Car
Type: CarLibrary.MiniVan
Type: CarLibrary.SportsCar
Метод
LoadFrom()
также может принимать абсолютный путь к файлу сборки, которую нужно просмотреть (скажем, С:\MyApp\MyAsm.dll
). Благодаря этому методу вы можете передавать полный путь в своем проекте консольного приложения. Таким образом, если файл CarLibrary.dll
находится в каталоге С:\MyCode
, тогда вы можете ввести С:\MyCode\CarLibrary
(обратите внимание, что расширение необязательно).
Метод
Assembly.Load()
имеет несколько перегруженных версий. Одна из них разрешает указывать значение культуры (для локализованных сборок), а также номер версии и значение маркера открытого ключа (для сборок инфраструктуры). Коллективно многочисленные элементы, идентифицирующие сборку, называются отображаемым именем. Форматом отображаемого имени является строка пар "имя-значение", разделенных запятыми, которая начинается с дружественного имени сборки, а за ним следуют необязательные квалификаторы (в любом порядке). Вот как выглядит шаблон (необязательные элементы указаны в круглых скобках):
Имя (,Version = <старший номер>.<младший номер>.<номер сборки>.сномер редакции>)
(,Culture = <маркер культуры>) (,PublicKeyToken = <маркер открытого ключа>)
При создании отображаемого имени соглашение
PublicKeyToken=null
отражает тот факт, что требуется связывание и сопоставление со сборкой, не имеющей строгого имени. Вдобавок Culture=""
указывает, что сопоставление должно осуществляться со стандартной культурой целевой машины. Вот пример:
// Загрузить версию 1.0.0.0 сборки CarLibrary, используя стандартную культуру
Assembly а = Assembly.Load(
"CarLibrary, Version=l.0.0.0, PublicKeyToken=null, Culture=\"\"" );
// В C# кавычки должны быть отменены с помощью символа обратной косой черты
Кроме того, следует иметь в виду, что пространство имен
System.Reflection
предлагает тип AssemblyName
, который позволяет представлять показанную выше строковую информацию в удобной объектной переменной. Обычно класс AssemblyName
применяется вместе с классом System.Version
, который представляет собой объектно-ориентированную оболочку для номера версии сборки. После создания отображаемого имени его затем можно передавать перегруженной версии метода Assembly.Load()
:
// Применение типа AssemblyName для определения отображаемого имени.
AssemblyName asmName;
asmName = new AssemblyName();
asmName.Name = "CarLibrary";
Version v = new Version("1.0.0.0");
asmName.Version = v;
Assembly a = Assembly.Load(asmName);
Чтобы загрузить сборку .NET Framework (не .NET Core), в параметре
Assembly.Load()
должно быть указано значение PublicKeyToken
. В .NET Core это не требуется из-за того, что назначение строгих имен используется реже. Например, создайте новый проект консольного приложения по имени FrameworkAssemblyViewer
, имеющий ссылку на пакет Microsoft.EntityFrameworkCore
. Как вам уже известно, это можно сделать в интерфейсе командной строки .NET 5 (CLI):
dotnet new console -lang c# -n FrameworkAssemblyViewer
-o .\FrameworkAssemblyViewer -f net5.0
dotnet sln .\Chapter17_AllProjects.sln add .\FrameworkAssemblyViewer
dotnet add .\FrameworkAssemblyViewer
package Microsoft.EntityFrameworkCore -v 5.0.0
Вспомните, что в случае ссылки на другую сборку копия этой сборки помещается в выходной каталог ссылаемого проекта. Скомпилируйте проект с применением CLI:
dotnet build
После создания проекта, добавления ссылки на
EntityFrameworkCode
и компиляции проекта сборку теперь можно загрузить и инспектировать. Поскольку количество типов в данной сборке довольно велико, приложение будет выводить только имена открытых перечислений, используя простой запрос LINQ:
using System;
using System.Linq;
using System.Reflection;
Console.WriteLine("***** The Framework Assembly Reflector App *****\n");
// Загрузить Microsoft.EntityFrameworkCore.dll
var displayName =
"Microsoft.EntityFrameworkCore, Version=5.0.0.0,
Culture=\"\", PublicKeyToken=adb9793829d
dae60";
Assembly asm = Assembly.Load(displayName);
DisplayInfo(asm);
Console.WriteLine("Done!");
Console.ReadLine();
private static void DisplayInfo(Assembly a)
{
Console.WriteLine("***** Info about Assembly *****");
Console.WriteLine($"Asm Name: {a.GetName().Name}" ); // Имя сборки
Console.WriteLine($"Asm Version: {a.GetName().Version}"); // Версия сборки
Console.WriteLine($"Asm Culture:
{a.GetName().CultureInfo.DisplayName}"); // Культура сборки
Console.WriteLine("\nHere are the public enums:");
// Список открытых перечислений.
// Использовать запрос LINQ для нахождения открытых перечислений.
Type[] types = a.GetTypes();
var publicEnums =
from pe in types
where pe.IsEnum && pe.IsPublic
select pe;
foreach (var pe in publicEnums)
{
Console.WriteLine(pe);
}
}
К настоящему моменту вы должны уметь работать с некоторыми основными членами пространства имен
System.Reflection
для получения метаданных во время выполнения. Конечно, необходимость в самостоятельном построении специальных браузеров объектов в повседневной практике вряд ли будет возникать часто. Однако не забывайте, что службы рефлексии являются основой для многих распространенных действий программирования, включая позднее связывание.
Позднее связывание представляет собой прием, который позволяет создавать экземпляр заданного типа и обращаться к его членам во время выполнения без необходимости в жестком кодировании факта его существования на этапе компиляции. При построении приложения, в котором производится позднее связывание с типом из внешней сборки, нет причин устанавливать ссылку на эту сборку; следовательно, в манифесте вызывающего кода она прямо не указывается.
На первый взгляд значимость позднего связывания оценить нелегко. Действительно, если есть возможность выполнить "раннее связывание" с объектом (например, добавить ссылку на сборку и выделить память под экземпляр типа с помощью ключевого слова
new
), то именно так следует поступать. Причина в том, что ранее связывание позволяет выявлять ошибки на этапе компиляции, а не во время выполнения. Тем не менее, позднее связывание играет важную роль в любом расширяемом приложении, которое может строиться. Пример построения такого "расширяемого" приложения будет приведен в конце главы, в разделе "Построение расширяемого приложения", а пока займемся исследованием роли класса Activator
.
Класс
System.Activator
играет ключевую роль в процессе позднего связывания .NET Core. В приведенном далее примере интересен только метод Activator.Createlnstance()
, который применяется для создания экземпляра типа через позднее связывание. Этот метод имеет несколько перегруженных версий, обеспечивая достаточно высокую гибкость. Самая простая версия метода CreateInstance()
принимает действительный объект Туре
, описывающий сущность, которую необходимо разместить в памяти на лету.
Создайте новый проект консольного приложения по имени
LateBindingApp
и с помощью ключевого слова using
импортируйте в него пространства имен System.IO
и System.Reflection
. Модифицируйте файл Program.cs
, как показано ниже:
using System;
using System.IO;
using System.Reflection;
// Это приложение будет загружать внешнюю сборку и
// создавать объект, используя позднее связывание.
Console.WriteLine("***** Fun with Late Binding *****");
// Попробовать загрузить локальную копию CarLibrary.
Assembly a = null;
try
{
a = Assembly.LoadFrom("CarLibrary");
}
catch(FileNotFoundException ex)
{
Console.WriteLine(ex.Message);
return;
}
if(a != null)
{
CreateUsingLateBinding(a);
}
Console.ReadLine();
static void CreateUsingLateBinding(Assembly asm)
{
try
{
// Получить метаданные для типа MiniVan.
Type miniVan = asm.GetType("CarLibrary.MiniVan");
// Создать экземпляр MiniVan на лету.
object obj = Activator.CreateInstance(miniVan);
Console.WriteLine("Created a {0} using late binding!", obj);
}
catch(Exception ex)
{
Console.WriteLine(ex.Message);
}
}
Перед запуском нового приложения понадобится вручную скопировать файл
CarLibrary.dll
в каталог с файлом проекта (или в подкаталог bin\Debug\net5.0
, если вы работаете в Visual Studio) данного приложения.
На заметку! Не добавляйте ссылку на
CarLibrary.dll
в этом примере! Вся суть позднего связывания заключается в попытке создания объекта, который не известен на этапе компиляции.
Обратите внимание, что метод
Activator.Createlnstance()
возвращает экземпляр System.Object
, а не строго типизированный объект MiniVan
. Следовательно, если применить к переменной obj
операцию точки, то члены класса MiniVan
не будут видны. На первый взгляд может показаться, что проблему удастся решить с помощью явного приведения:
// Привести к типу MiniVan, чтобы получить доступ к его членам?
// Нет! Компилятор сообщит об ошибке!
object obj = (MiniVan)Activator.CreateInstance(minivan);
Однако из-за того, что в приложение не была добавлена ссылка на сборку
CarLibrary.dll
, использовать ключевое слово using
для импортирования пространства имен CarLibrary
нельзя, а значит невозможно и указывать тип MiniVan
в операции приведения! Не забывайте, что смысл позднего связывания — создание экземпляров типов, о которых на этапе компиляции ничего не известно. Учитывая сказанное, возникает вопрос: как вызывать методы объекта MiniVan
, сохраненного в ссылке на System.Object
? Ответ: конечно же, с помощью рефлексии.
Предположим, что требуется вызвать метод
TurboBoost()
объекта MiniVan
. Вспомните, что упомянутый метод переводит двигатель в нерабочее состояние и затем отображает окно с соответствующим сообщением. Первый шаг заключается в получении объекта MethodInf
о для метода TurboBoost()
посредством Туре.GetMethod()
. Имея результирующий объект MethodInfо
, можно вызвать MiniVan.TurboBoost()
с помощью метода Invoke()
. Метод MethodInfо.Invoke()
требует указания всех параметров, которые подлежат передаче методу, представленному объектом MethodInfо
. Параметры задаются в виде массива объектов System.Object
(т.к. они могут быть самыми разнообразными сущностями).
Поскольку метод
TurboBoost()
не принимает параметров, можно просто передать null
(т.е. сообщить, что вызываемый метод не имеет параметров). Обновите метод CreateUsingLateBinding()
следующим образом:
static void CreateUsingLateBinding(Assembly asm)
{
try
{
// Получить метаданные для типа Minivan.
Type miniVan = asm.GetType("CarLibrary.MiniVan");
// Создать объект MiniVan на лету.
object obj = Activator.CreateInstance(miniVan);
Console.WriteLine($"Created a {obj} using late binding!");
// Получить информацию о TurboBoost.
MethodInfo mi = miniVan.GetMethod("TurboBoost");
// Вызвать метод (null означает отсутствие параметров).
mi.Invoke(obj, null);
}
catch(Exception ex)
{
Console.WriteLine(ex.Message);
}
}
Теперь после запуска приложения вы увидите в окне консоли сообщение о том, что двигатель вышел из строя (
"Eek! Your engine block exploded!"
).
Когда позднее связывание нужно применять для вызова метода, ожидающего параметры, аргументы потребуется упаковать в слабо типизированный массив
object
. В версии класса Car
с радиоприемником был определен следующий метод:
public void TurnOnRadio(bool musicOn, MusicMediaEnum mm)
=> MessageBox.Show(musicOn ? $"Jamming {mm}" : "Quiet time...");
Метод
TurnOnRadio()
принимает два параметра: булевское значение, которое указывает, должна ли быть включена музыкальная система в автомобиле, и перечисление, представляющее тип музыкального проигрывателя. Вспомните, что это перечисление определено так:
public enum MusicMediaEnum
{
musicCd, // 0
musicTape, // 1
musicRadio, // 2
musicMp3 // 3
}
Ниже приведен код нового метода класса
Program
, в котором вызывается TurnOnRadio()
. Обратите внимание на использование внутренних числовых значений перечисления MusicMediaEnum
:
static void InvokeMethodWithArgsUsingLateBinding(Assembly asm)
{
try
{
// Получить описание метаданных для типа SportsCar.
Type sport = asm.GetType("CarLibrary.SportsCar");
// Создать объект типа SportsCar.
object obj = Activator.CreateInstance(sport);
// Вызвать метод TurnOnRadio() с аргументами.
MethodInfo mi = sport.GetMethod("TurnOnRadio");
mi.Invoke(obj, new object[] { true, 2 });
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
В идеале к настоящему времени вы уже видите отношения между рефлексией, динамической загрузкой и поздним связыванием. Естественно, помимо раскрытых здесь возможностей API-интерфейс рефлексии предлагает много дополнительных средств, но вы уже должны быть в хорошей форме, чтобы погрузиться в дальнейшее изучение.
Вас все еще может интересовать вопрос: когда описанные приемы должны применяться в разрабатываемых приложениях? Ответ прояснится ближе к концу главы, а пока мы займемся исследованием роли атрибутов .NET Core.
Как было показано в начале главы, одной из задач компилятора .NET Core является генерация описаний метаданных для всех определяемых типов и для типов, на которые имеются ссылки. Помимо стандартных метаданных, содержащихся в любой сборке, платформа .NET Core предоставляет программистам способ встраивания в сборку дополнительных метаданных с использованием атрибутов. Выражаясь кратко, атрибуты — это всего лишь аннотации кода, которые могут применяться к заданному типу (классу, интерфейсу, структуре и т.п.), члену (свойству, методу и т.д.), сборке или модулю.
Атрибуты .NET Core представляют собой типы классов, расширяющие абстрактный базовый класс
System.Attribute
. По мере изучения пространств имен .NET Core вы найдете много предопределенных атрибутов, которые можно использовать в своих приложениях. Более того, вы также можете строить собственные атрибуты для дополнительного уточнения поведения своих типов путем создания нового типа, производного от Attribute
.
Библиотеки базовых классов .NET Core предлагают атрибуты в различных пространствах имен. В табл. 17.3 описаны некоторые (безусловно, далеко не все) предопределенные атрибуты.
Важно понимать, что когда вы применяете атрибуты в своем коде, встроенные метаданные по существу бесполезны до тех пор, пока другая часть программного обеспечения явно не запросит такую информацию посредством рефлексии. В противном случае метаданные, встроенные в сборку, игнорируются и не причиняют никакого вреда.
Как нетрудно догадаться, в состав .NET Core входят многочисленные утилиты, которые действительно ищут разнообразные атрибуты. Сам компилятор C# (
csc.exe
) запрограммирован на обнаружение различных атрибутов при проведении компиляции. Например, встретив атрибут [CLSCompilant]
, компилятор автоматически проверяет помеченный им элемент и удостоверяется в том, что в нем открыт доступ только к конструкциям, совместимым с CLS. Еще один пример: если компилятор обнаруживает элемент с атрибутом [Obsolete]
, тогда он отображает в окне Error List (Список ошибок) среды Visual Studio сообщение с предупреждением.
В дополнение к инструментам разработки многие методы в библиотеках базовых классов . NET Core изначально запрограммированы на распознавание определенных атрибутов посредством рефлексии. В главе 20 рассматривается сериализация XML и JSON, которая задействует атрибуты для управления процессом сериализации.
Наконец, можно строить приложения, способные распознавать специальные атрибуты, а также любые атрибуты из библиотек базовых классов .NET Core. По сути, тем самым создается набор "ключевых слов", которые понимает специфичное множество сборок.
Чтобы ознакомиться со способами применения атрибутов в С#, создайте новый проект консольного приложения по имени
ApplyingAttributes
и добавьте ссылку на System.Text.Json
. Предположим, что необходимо построить класс под названием Motorcycle
(мотоцикл), который может сохраняться в формате JSON. Если какое-то поле сохраняться не должно, тогда к нему следует применить атрибут [JsonIgnore]
.
public class Motorcycle
{
[JsonIgnore]
public float weightOfCurrentPassengers;
// Эти поля остаются сериализируемыми.
public bool hasRadioSystem;
public bool hasHeadSet;
public bool hasSissyBar;
}
На заметку! Атрибут применяется только к элементу, находящемуся непосредственно после него.
В данный момент пусть вас не беспокоит фактический процесс сериализации объектов (он подробно рассматривается в главе 20). Просто знайте, что для применения атрибута его имя должно быть помещено в квадратные скобки.
Нетрудно догадаться, что к одиночному элементу можно применять множество атрибутов. Предположим, что у вас есть унаследованный тип класса C# (
НоrseAndBuggy
), который был снабжен атрибутом, чтобы иметь специальное пространство имен XML. Со временем кодовая база изменилась, и класс теперь считается устаревшим для текущей разработки. Вместо того чтобы удалять определение такого класса из кодовой базы (с риском нарушения работы существующего программного обеспечения), его можно пометить атрибутом [Obsolete]
. Для применения множества атрибутов к одному элементу просто используйте список с разделителями-запятыми:
using System;
using System.Xml.Serialization;
namespace ApplyingAttributes
{
[XmlRoot(Namespace = "http://www.MyCompany.com"),
Obsolete("Use another vehicle!")]
// Используйте другое транспортное средство!
public class HorseAndBuggy
{
// ...
}
}
В качестве альтернативы применить множество атрибутов к единственному элементу можно также, указывая их друг за другом (конечный результат будет идентичным):
[XmlRoot(Namespace = "http://www.MyCompany.com")]
[Obsolete("Use another vehicle!")]
public class HorseAndBuggy
{
// ...
}
Заглянув в документацию по .NET Core, вы можете заметить, что действительным именем класса, представляющего атрибут
[Obsolete]
, является ObsoleteAttribute
, а не просто Obsolete
. По соглашению имена всех атрибутов .NET Core (включая специальные атрибуты, которые создаете вы сами) снабжаются суффиксом Attribute
. Тем не менее, чтобы упростить процесс применения атрибутов, в языке C# не требуется обязательный ввод суффикса Attribute
. Учитывая это, показанная ниже версия типа HorseAndBuggy
идентична предыдущей версии (но влечет за собой более объемный клавиатурный набор):
[SerializableAttribute]
[ObsoleteAttribute("Use another vehicle!")]
public class HorseAndBuggy
{
// ...
}
Имейте в виду, что такая сокращенная система обозначения для атрибутов предлагается только в С#. Ее поддерживают не все языки .NET Core.
Обратите внимание, что атрибут
[Obsolete]
может принимать то, что выглядит как параметр конструктора. Если вы просмотрите формальное определение атрибута [Obsolete]
, щелкнув на нем правой кнопкой мыши в окне кода и выбрав в контекстном меню пункт Go То Definition (Перейти к определению), то обнаружите, что данный класс на самом деле предоставляет конструктор, принимающий System.String
:
public sealed class ObsoleteAttribute : Attribute
{
public ObsoleteAttribute(string message, bool error);
public ObsoleteAttribute(string message);
public ObsoleteAttribute();
public bool IsError { get; }
public string? Message { get; }
}
Важно понимать, что когда вы снабжаете атрибут параметрами конструктора, этот атрибут не размещается в памяти до тех пор, пока к параметрам не будет применена рефлексия со стороны другого типа или внешнего инструмента. Строковые данные, определенные на уровне атрибутов, просто сохраняются внутри сборки в виде блока метаданных.
Теперь, поскольку класс
HorseAndBuggy
помечен как устаревший, следующая попытка выделения памяти под его экземпляр:
using System;
using ApplyingAttributes;
Console.WriteLine("Hello World!");
HorseAndBuggy mule = new HorseAndBuggy();
приводит к выдаче компилятором предупреждающего сообщения, а именно — предупреждения CS0618 с сообщением, включающим информацию, которая передавалась атрибуту:
‘HorseAndBuggy’ is obsolete: ‘Use another vehicle!'
HorseAndBuggy устарел: Используйте другое транспортное средство!
Среды Visual Studio и Visual Studio Code оказывают помощь также посредством IntelliSense, получая информацию через рефлексию.
На рис. 17.1 показаны результаты действия атрибута
[Obsolete]
в Visual Studio, а на рис. 17.2 — в Visual Studio Code. Обратите внимание, что в обеих средах используется термин deprecated вместо obsolete.
В идеальном случае к настоящему моменту вы уже должны понимать перечисленные ниже ключевые моменты, касающиеся атрибутов .NET Core:
• атрибуты представляют собой классы, производные от
System.Attribute
;
• атрибуты дают в результате встроенные метаданные;
• атрибуты в основном бесполезны до тех пор, пока другой агент не проведет в их отношении рефлексию;
• атрибуты в языке C# применяются с использованием квадратных скобок.
А теперь давайте посмотрим, как реализовывать собственные специальные атрибуты и создавать специальное программное обеспечение, которое выполняет рефлексию по встроенным метаданным.
Первый шаг при построении специального атрибута предусматривает создание нового класса, производного от
System.Attribute
. Не отклоняясь от автомобильной темы, повсеместно встречающейся в книге, создайте новый проект типа Class Library (Библиотека классов) на C# под названием AttributedCarLibrary
. В этой сборке будет определено несколько классов для представления транспортных средств, каждый из которых описан с использованием специального атрибута по имени VehicleDescriptionAttribute
:
using System;
// Специальный атрибут.
public sealed class VehicleDescriptionAttribute :Attribute
{
public string Description { get; set; }
public VehicleDescriptionAttribute(string description)
=> Description = description;
public VehicleDescriptionAttribute(){ }
}
Как видите, класс
VehicleDescriptionAttribute
поддерживает фрагмент строковых данных, которым можно манипулировать с помощью автоматического свойства (Description
). Помимо того факта, что данный класс является производным от System.Attribute
, ничего примечательного в его определении нет.
На заметку! По причинам, связанным с безопасностью, установившейся практикой в .NET Core считается проектирование всех специальных атрибутов как запечатанных. На самом деле среды Visual Studio и Visual Studio Code предлагают фрагмент кода под названием
Attribute
, который позволяет сгенерировать в окне редактора кода новый класс, производный от System.Attribute
. Для раскрытия любого фрагмента кода необходимо набрать его имя и нажать клавишу <ТаЬ> (один раз в Visual Studio Code и два раза в Visual Studio).
С учетом того, что класс
VehicleDescriptionAttribute
является производным от System.Attribute
, теперь можно аннотировать транспортные средства. В целях тестирования добавьте в новую библиотеку классов следующие файлы классов:
// Motorcycle.cs
namespace AttributedCarLibrary
{
// Назначить описание с помощью "именованного свойства".
[Serializable]
[VehicleDescription(Description = "My rocking Harley")]
// Мой покачивающийся Харли
public class Motorcycle
{
}
// HorseAndBuggy.cs
namespace AttributedCarLibrary
{
[Serializable]
[Obsolete ("Use another vehicle!")]
[VehicleDescription("The old gray mare, she ain't what she used to be...")]
// Старая серая лошадка, она уже не та...
public class HorseAndBuggy
{
}
}
// Winnebago.cs
namespace AttributedCarLibrary
{
[VehicleDescription("A very long, slow, but feature-rich auto")]
// Очень длинный, медленный, но обладающий высокими
// техническими характеристиками автомобиль
public class Winnebago
{
}
}
Обратите внимание, что классу
Motorcycle
назначается описание с использованием нового фрагмента синтаксиса, связанного с атрибутами, который называется именованным свойством. В конструкторе первого атрибута [VehicleDescription]
лежащие в основе строковые данные устанавливаются с применением свойства Description
. Когда внешний агент выполнит рефлексию для этого атрибута, свойству Description
будет передано указанное значение (синтаксис именованных свойств разрешен, только если атрибут предоставляет поддерживающее запись свойство .NET Core).
По контрасту для типов
HorseAndBuggy
и Winnebago
синтаксис именованных свойств не используется, а строковые данные просто передаются через специальный конструктор. В любом случае после компиляции сборки AttributedCarLibrary
с помощью утилиты ildasm.exe
можно просмотреть добавленные описания метаданных. Например, ниже показано встроенное описание класса Winnebago
:
// CustomAttribute #1
// -------------------------------------------------------
// CustomAttribute Type: 06000005
// CustomAttributeName: AttributedCarLibrary.VehicleDescriptionAttribute :: instance void
.ctor(class System.String)
// Length: 45
// Value : 01 00 28 41 20 76 65 72 79 20 6c 6f 6e 67 2c 20 > (A very long, <
// : 73 6c 6f 77 2c 20 62 75 74 20 66 65 61 74 75 72 >slow, but feature<
// : 65 2d 72 69 63 68 20 61 75 74 6f 00 00 >e-rich auto <
// ctor args: ("A very long, slow, but feature-rich auto")
По умолчанию специальные атрибуты могут быть применены практически к любому аспекту кода (методам, классам, свойствам и т.д.). Таким образом, если бы это имело смысл, то
VehicleDescription
можно было бы использовать для уточнения методов, свойств или полей (помимо прочего):
[VehicleDescription("A very long, slow, but feature-rich auto")]
public class Winnebago
{
[VehicleDescription("My rocking CD player")]
public void PlayMusic(bool On)
{
...
}
}
В одних случаях такое поведение является точно таким, какое требуется, но в других может возникнуть желание создать специальный атрибут, применяемый только к избранным элементам кода. Чтобы ограничить область действия специального атрибута, понадобится добавить к его определению атрибут
[AttributeUsage]
, который позволяет предоставлять любую комбинацию значений (посредством операции "ИЛИ") из перечисления AttributeTargets
:
// Это перечисление определяет возможные целевые элементы для атрибута.
public enum AttributeTargets
{
All, Assembly, Class, Constructor,
Delegate, Enum, Event, Field, GenericParameter,
Interface, Method, Module, Parameter,
Property, ReturnValue, Struct
}
Кроме того, атрибут
[AttributeUsage]
допускает необязательную установку именованного свойства(AllowMultiple
), которое указывает, может ли атрибут применяться к тому же самому элементу более одного раза (стандартным значением является false
). Вдобавок [AttributeUsage]
разрешает указывать, должен ли атрибут наследоваться производными классами, с использованием именованного свойства Inherited
(со стандартным значением true
).
Модифицируйте определение
VehicleDescriptionAttribute
для указания на то, что атрибут [VehicleDescription]
может применяться только к классу или структуре:
// На этот раз для аннотирования специального атрибута
// используется атрибут AttributeUsage.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)]
public sealed class VehicleDescriptionAttribute : System.Attribute
{
...
}
Теперь если разработчик попытается применить атрибут
[VehicleDescription]
не к классу или структуре, то компилятор сообщит об ошибке.
Атрибуты можно также применять ко всем типам внутри отдельной сборки, используя дескриптор
[assembly:]
. Например, предположим, что необходимо обеспечить совместимость с CLS для всех открытых членов во всех открытых типах, определенных внутри сборки. Рекомендуется добавить в проект новый файл по имени AssemblyAttributes.cs
(не AssemblyInfо.cs
, т.к. он генерируется автоматически) и поместить в него атрибуты уровня сборки.
На заметку! Какая-либо формальная причина для использования отдельного файла отсутствует; это связано чисто с удобством поддержки вашего кода. Помещение атрибутов сборки в отдельный файл проясняет тот факт, что в вашем проекте используются атрибуты уровня сборки, и показывает, где они находятся.
При добавлении в проект атрибутов уровня сборки или модуля имеет смысл придерживаться следующей рекомендуемой схемы для файла кода:
// Первыми перечислить операторы using.
using System;
// Теперь перечислить атрибуты уровня сборки или модуля.
// Обеспечить совместимость с CLS для всех открытых типов в данной сборке.
[assembly: CLSCompliant(true)]
Если теперь добавить фрагмент кода, выходящий за рамки спецификации CLS (вроде открытого элемента данных без знака), тогда компилятор выдаст предупреждение:
// Тип ulong не соответствует спецификации CLS.
public class Winnebago
{
public ulong notCompliant;
}
На заметку! В .NET Core внесены два значительных изменения. Первое касается того, что файл
AssemblyInfo.cs
теперь генерируется автоматически из свойств проекта и настраивать его не рекомендуется. Второе (и связанное) изменение заключается в том, что многие из предшествующих атрибутов уровня сборки (Version
, Company
и т.д.) были заменены свойствами в файле проекта.
Как было демонстрировалось в главе 16 с классом
InternalsVisibleToAttribute
, атрибуты сборки можно также добавлять в файл проекта. Загвоздка здесь в том, что применять подобным образом можно только однострочные атрибуты параметров. Это справедливо для свойств, которые могут устанавливаться на вкладке Package (Пакет) в окне свойств проекта.
На заметку! На момент написания главы в хранилище GitHub для MSBuild шло активное обсуждение относительно добавления возможности поддержки нестроковых параметров, что позволило бы добавлять атрибут
CLSCompliant
с использованием файла проекта вместо файла *.cs
.
Установите несколько свойств (таких как
Authors
, Description
), щелкнув правой кнопкой мыши на имени проекта в окне Solution Explorer, выберите в контекстном меню пункт Properties (Свойства) и в открывшемся окне свойств перейдите на вкладку Package. Кроме того, добавьте InternalsVisibleToAttribute
, как делалось в главе 16. Содержимое вашего файла проекта должно выглядеть примерно так, как представленное ниже:
net5.0
Philip Japikse
Apress
This is a simple car library with attributes
<_Parameter1>CSharpCarClient
После компиляции своего проекта перейдите в каталог \
obj\Debug\net5.0
и отыщите файл AttributedCarLibrary.AssemblyInfo.cs
. Открыв его, вы увидите установленные свойства в виде атрибутов (к сожалению, они не особо читабельны в таком формате):
using System;
using System.Reflection;
[assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute
("CSharpCarClient")]
[assembly: System.Reflection.AssemblyCompanyAttribute("Philip Japikse")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyDescriptionAttribute("This is a
sample car library with
attributes")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("AttributedCarLibrary")]
[assembly: System.Reflection.AssemblyTitleAttribute("AttributedCarLibrary")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
И последнее замечание, касающееся атрибутов сборки: вы можете отключить генерацию файла
AssemblyInfо.cs
, если хотите управлять процессом самостоятельно.
Вспомните, что атрибуты остаются бесполезными до тех пор, пока к их значениям не будет применена рефлексия в другой части программного обеспечения. После обнаружения атрибута другая часть кода может предпринять необходимый образ действий. Подобно любому приложению "другая часть программного обеспечения" может обнаруживать присутствие специального атрибута с использованием либо раннего, либо позднего связывания. Для применения раннего связывания определение интересующего атрибута (в данном случае
VehicleDescriptionAttribute
) должно находиться в клиентском приложении на этапе компиляции. Учитывая то, что специальный атрибут определен в сборке AttributedCarLibrary
как открытый класс, раннее связывание будет наилучшим выбором.
Чтобы проиллюстрировать процесс рефлексии специальных атрибутов, вставьте в решение новый проект консольного приложения по имени
VehicleDescriptionAttributeReader
. Добавьте в него ссылку на проект AttributedCarLibrary
. Выполните приведенные далее команды CLI (каждая должна вводиться по отдельности):
dotnet new console -lang c# -n VehicleDescriptionAttributeReader -o .\
VehicleDescriptionAttributeReader -f net5.0
dotnet sln .\Chapter17_AllProjects.sln add .\VehicleDescriptionAttributeReader
dotnet add VehicleDescriptionAttributeReader reference .\AttributedCarLibrary
Поместите в файл
Program.сs
следующий код:
using System;
using AttributedCarLibrary;
Console.WriteLine("***** Value of VehicleDescriptionAttribute *****\n");
ReflectOnAttributesUsingEarlyBinding();
Console.ReadLine();
static void ReflectOnAttributesUsingEarlyBinding()
{
// Получить объект Type, представляющий тип Winnebago.
Type t = typeof(Winnebago);
// Получить все атрибуты Winnebago.
object[] customAtts = t.GetCustomAttributes(false);
// Вывести описание.
foreach (VehicleDescriptionAttribute v in customAtts)
{
Console.WriteLine("-> {0}\n", v.Description);
}
}
Метод
Type.GetCustomAttributes()
возвращает массив объектов со всеми атрибутами, примененными к члену, который представлен объектом Туре
(булевский параметр управляет тем, должен ли поиск распространяться вверх по цепочке наследования). После получения списка атрибутов осуществляется проход по всем элементам VehicleDescriptionAttribute
с отображением значения свойства Description
.
В предыдущем примере для вывода описания транспортного средства типа Winnebago применялось ранее связывание. Это было возможно благодаря тому, что тип класса
VehicleDescriptionAttribute
определен в сборке AttributedCarLibrary
как открытый член. Для рефлексии атрибутов также допускается использовать динамическую загрузку и позднее связывание.
Добавьте к решению новый проект консольного приложения по имени
VehicleDescriptionAttributeReaderLateBinding
, установите его в качестве стартового и скопируйте сборку AttributedCarLibrary.dll
в каталог проекта (или в \bin\Debug\net5.0
, если вы работаете в Visual Studio). Модифицируйте файл Program.cs
, как показано ниже:
using System;
using System.Reflection;
Console.WriteLine("***** Value of VehicleDescriptionAttribute *****\n");
ReflectAttributesUsingLateBinding();
Console.ReadLine();
static void ReflectAttributesUsingLateBinding()
{
try
{
// Загрузить локальную копию сборки AttributedCarLibrary.
Assembly asm = Assembly.LoadFrom("AttributedCarLibrary");
// Получить информацию о типе VehicleDescriptionAttribute.
Type vehicleDesc =
asm.GetType("AttributedCarLibrary.VehicleDescriptionAttribute");
// Получить информацию о типе свойства Description.
PropertyInfo propDesc = vehicleDesc.GetProperty("Description");
// Получить все типы в сборке.
Type[] types = asm.GetTypes();
// Пройти по всем типам и получить любые атрибуты VehicleDescriptionAttribute.
foreach (Type t in types)
{
object[] objs = t.GetCustomAttributes(vehicleDesc, false);
// Пройти по каждому VehicleDescriptionAttribute и вывести
// описание, используя позднее связывание.
foreach (object o in objs)
{
Console.WriteLine("-> {0}: {1}\n", t.Name,
propDesc.GetValue(o, null));
}
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
Если вы прорабатывали примеры, рассмотренные ранее в главе, тогда приведенный код должен быть более или менее понятен. Единственный интересный момент здесь связан с применением метода
PropertyInfo.GetValue()
, который служит для активизации средства доступа к свойству. Вот как выглядит вывод, полученный в результате выполнения текущего примера:
***** Value of VehicleDescriptionAttribute *****
-> Motorcycle: My rocking Harley
-> HorseAndBuggy: The old gray mare, she ain't what she used to be...
-> Winnebago: A very long, slow, but feature-rich auto
Хотя вы видели многочисленные примеры применения этих приемов, вас по-прежнему может интересовать, когда использовать рефлексию, динамическое связывание и специальные атрибуты в своих программах. Действительно, данные темы могут показаться в большей степени относящимися к академической стороне программирования (что в зависимости от вашей точки зрения может быть как отрицательным, так и положительным аспектом). Для содействия в отображении указанных тем на реальные ситуации необходим более серьезный пример. Предположим, что вы работаете в составе команды программистов, которая занимается построением приложения, соблюдая требование о том, что продукт должен быть расширяемым за счет использования добавочных сторонних инструментов.
Что понимается под расширяемостью? Возьмем IDE -среду Visual Studio. Когда это приложение разрабатывалось, в его кодовую базу были вставлены многочисленные "привязки", чтобы позволить другим производителям программного обеспечения подключать специальные модули к IDE - среде. Очевидно, что у разработчиков Visual Studio отсутствовал какой-либо способ установки ссылок на внешние сборки .NET Core, которые на тот момент еще не были созданы (и потому раннее связывание недоступно), тогда как они обеспечили наличие в приложении необходимых привязок? Ниже представлен один из возможных способов решения задачи.
1. Во-первых, расширяемое приложение должно предоставлять некоторый механизм ввода, позволяющий пользователю указать модуль для подключения (наподобие диалогового окна или флага командной строки). Это требует динамической загрузки.
2. Во-вторых, расширяемое приложение должно иметь возможность выяснять, поддерживает ли модуль корректную функциональность (такую как набор обязательных интерфейсов), необходимую для его подключения к среде. Это требует рефлексии.
3. В-третьих, расширяемое приложение должно получать ссылку на требуемую инфраструктуру (вроде набора интерфейсных типов) и вызывать члены для запуска лежащей в основе функциональности. Это может требовать позднего связывания.
Попросту говоря, если расширяемое приложение изначально запрограммировано для запрашивания специфических интерфейсов, то во время выполнения оно в состоянии выяснять, может ли быть активизирован интересующий тип. После успешного прохождения такой проверки тип может поддерживать дополнительные интерфейсы, которые формируют полиморфную фабрику его функциональности. Именно этот подход был принят командой разработчиков Visual Studio, и вопреки тому, что вы могли подумать, в нем нет ничего сложного!
В последующих разделах будет рассмотрен пример создания расширяемого приложения, которое может быть дополнено функциональностью внешних сборок. Расширяемое приложение образовано из следующих сборок.
•
CommonSnappableTypes.dll
. Эта сборка содержит определения типов, которые будут использоваться каждым объектом оснастки. На нее будет напрямую ссылаться расширяемое приложение.
•
CSharpSnapIn.dll
. Оснастка, написанная на С#, в которой задействованы типы из сборки CommonSnappableTypes.dll
.
•
VBSnapIn.dll
. Оснастка, написанная на Visual Basic, в которой применяются типы из сборки CommonSnappableTypes.dll
.
•
MyExtendableApp.ехе
. Консольное приложение, которое может быть расширено функциональностью каждой оснастки.
В приложении будут использоваться динамическая загрузка, рефлексия и позднее связывание для динамического получения функциональности сборок, о которых заранее ничего не известно.
На заметку! Вы можете подумать о том, что вам вряд ли будет ставиться задача построения консольного приложения, и тут вы вероятно правы! Бизнес-приложения, создаваемые на языке С#, обычно относятся к категории интеллектуальных клиентов (Windows Forms или WPF), веб-приложений/служб REST (ASP.NET Core) или автоматических процессов (функций Azure, служб Windows и т.д.). Консольные приложения применяются здесь, чтобы сосредоточиться на специфических концепциях примеров, в данном случае — на динамической загрузке, рефлексии и позднем связывании. Позже в книге вы узнаете, как строить "реальные" пользовательские приложения с использованием WPF и ASP.NET Core.
Большинство приложений, созданных ранее в книге, были автономными проектами с небольшими исключениями (вроде предыдущего приложения). Так делалось для того, чтобы сохранять примеры простыми и четко ориентированными на демонстрируемые в них аспекты. Однако в реальном процессе разработки обычно приходится работать с множеством проектов в одном решении.
Открыв окно интерфейса CLI, введите следующие команды, чтобы создать новое решение, проекты для библиотек классов и консольного приложения, а также ссылки на проекты:
dotnet new sln -n Chapter17_ExtendableApp
dotnet new classlib -lang c# -n CommonSnappableTypes
-o .\CommonSnappableTypes -f net5.0
dotnet sln .\Chapter17_ExtendableApp.sln add .\CommonSnappableTypes
dotnet new classlib -lang c# -n CSharpSnapIn -o .\CSharpSnapIn -f net5.0
dotnet sln .\Chapter17_ExtendableApp.sln add .\CSharpSnapIn
dotnet add CSharpSnapin reference CommonSnappableTypes
dotnet new classlib -lang vb -n VBSnapIn -o .\VBSnapIn -f net5.0
dotnet sln .\Chapter17_ExtendableApp.sln add .\VBSnapIn
dotnet add VBSnapIn reference CommonSnappableTypes
dotnet new console -lang c# -n MyExtendableApp -o .\MyExtendableApp -f net5.0
dotnet sln .\Chapter17_ExtendableApp.sln add .\MyExtendableApp
dotnet add MyExtendableApp reference CommonSnappableTypes
При компиляции проекта (либо в Visual Studio, либо в командной строке) существуют события, к которым можно привязываться. Например, после каждой успешной компиляции нужно копировать две сборки оснасток в каталог проекта консольного приложения (в случае отладки посредством
dotnet run
) и в выходной каталог консольного приложения (при отладке в Visual Studio). Для этого будут использоваться несколько встроенных макросов.
Вставьте в файлы
CSharpSnapin.csproj
и VBSnapIn.vbproj
приведенный ниже блок разметки, который копирует скомпилированную сборку в каталог проекта MyExtendableApp
и в выходной каталог(MyExtendableApp\bin\debug\net5.0
):
Теперь после компиляции каждого проекта его сборка копируется также в целевой каталог приложения
MyExtendableApp
.
Вспомните, что по умолчанию среда Visual Studio назначает решению такое же имя, как у первого проекта, созданного в этом решении. Тем не менее, вы можете легко изменять имя решения.
Чтобы создать решение
ExtendableApp
, выберите в меню пункт File►New Project (Файл►Создать проект). В открывшемся диалоговом окне Add New Project (Добавление нового проекта) выберите элемент Class Library (Библиотека классов) и введите CommonSnappableTypes
в поле Project name (Имя проекта). Прежде чем щелкать на кнопке Create (Создать), введите ExtendableApp
в поле Solution name (Имя решения), как показано на рис. 17.3.
Чтобы добавить к решению еще один проект, щелкните правой кнопкой мыши на имени решения(
ExtendableApp
) в окне Solution Explorer и выберите в контекстном меню пункт Add►New Project (Добавить► Новый проект) или выберите в меню пункт File►Add►New Project (Файл►Добавить►Новый проект). При добавлении дополнительного проекта к существующему решению содержимое диалогового окна Add New Project слегка отличается; параметры решения теперь отсутствуют, так что вы увидите только информацию о проекте (имя и местоположение). Назначьте проекту библиотеки классов имя CSharpSnapIn
и щелкните на кнопке Create.
Далее добавьте в проект
CSharpSnapIn
ссылку на проект CommonSnappableTypes
. В среде Visual Studio щелкните правой кнопкой мыши на имени проекта CSharpSnapIn
и выберите в контекстном меню пункт Add►Project Reference (Добавить►Ссылка на проект). В открывшемся диалоговом окне Reference Manager (Диспетчер ссылок) выберите элемент Projects►Solution (Проекты►Решение) в левой части (если он еще не выбран) и отметьте флажок рядом с CommonSnappableTypes
.
Повторите процесс для нового проекта библиотеки классов Visual Basic (VBSnapIn), которая ссылается на проект
CommonSnappableTypes
.
Наконец, добавьте к решению новый проект консольного приложения .NET Core по имени
MyExtendableApp
. Добавьте в него ссылку на проект CommonSnappableTypes
и установите проект консольного приложения в качестве стартового для решения. Для этого щелкните правой кнопкой мыши на имени проекта MyExtendableApp
в окне Solution Explorer и выберите в контекстном меню пункт Set as Startup Project (Установить как стартовый проект).
На заметку! Если вы щелкнете правой кнопкой мыши на имени решения
ExtendableApp
, а не на имени одного из проектов, то в контекстном меню отобразится пункт Set Startup Projects (Установить стартовые проекты). Помимо запуска по щелчку на кнопке Run (Запустить) только одного проекта можно настроить запуск множества проектов, что будет демонстрироваться позже в книге.
Когда среде Visual Studio поступает команда запустить решение, стартовый проект и все проекты, на которые имеются ссылки, компилируются в случае обнаружения любых изменений; однако проекты, ссылки на которые отсутствуют, не компилируются. Положение дел можно изменить, устанавливая зависимости проектов. Для этого щелкните правой кнопкой мыши на имени решения в окне Solution Explorer, выберите в контекстном меню пункт Project Build Order (Порядок компиляции проектов), в открывшемся диалоговом окне перейдите на вкладку Dependencies (Зависимости) и в раскрывающемся списке Projects (Проекты) выберите
MyExtendableApp
.
Обратите внимание, что проект
CommonSnappableTypes
уже выбран и связанный с ним флажок отключен. Причина в том, что на него производится ссылка напрямую. Отметьте также флажки для проектов CSharpSnapIn
и VBSnapIn
(рис. 17.4).
Теперь при каждой компиляции проекта
MyExtendableApp
будут также компилироваться проекты CSharpSnapIn
и VBSnapIn
.
Откройте окно свойств проекта для
CSharpSnapIn
(щелкнув правой кнопкой мыши на имени проекта в окне Solution Explorer и выбрав в контекстном меню пункт Properties (Свойства)) и перейдите в нем на вкладку Build Events (События при компиляции). Щелкните на кнопке Edit Post-build (Редактировать события после компиляции) и затем щелкните на Macros>> (Макросы). Здесь вы можете видеть доступные для использования макросы, которые ссылаются на пути и/или имена файлов. Преимущество применения этих макросов в событиях, связанных с компиляцией, заключается в том, что они не зависят от машины и работают с относительными путями. Скажем, кто-то работает в каталоге по имени c-sharp-wf\code\chapterl7
. Вы можете работать в другом каталоге (вероятнее всего так и есть). За счет применения макросов инструмент MSBuild всегда будет использовать корректный путь относительно файлов *.csproj
.
Введите в области PostBuild (После компиляции) следующие две строки:
copy $(TargetPath) $(SolutionDir)MyExtendableApp\$(OutDir)$(TargetFileName) /Y
copy $(TargetPath) $(SolutionDir)MyExtendableApp\$(TargetFileName) /Y
Сделайте то же самое для проекта
VBSnapin
, но здесь вкладка в окне свойств называется Compile (Компиляция) и на ней понадобится щелкнуть на кнопке Build Events (События при компиляции).
Когда показанные выше команды событий после компиляции добавлены, все сборки при каждой компиляции будут копироваться в каталог проекта и выходной каталог приложения
MyExtendableApp
.
Удалите стандартный файл класса
Class1.cs
из проекта CommonSnappableTypes
, добавьте новый файл интерфейса по имени AppFunctionality.cs
и поместите в него следующий код:
namespace CommonSnappableTypes
{
public interface IAppFunctionality
{
void DoIt();
}
}
Добавьте файл класса по имени
CompanyInfoAttribute.cs
и приведите его содержимое к такому виду:
using System;
namespace CommonSnappableTypes
{
[AttributeUsage(AttributeTargets.Class)]
public sealed class CompanyInfoAttribute : System.Attribute
{
public string CompanyName { get; set; }
public string CompanyUrl { get; set; }
}
}
Тип
IAppFunctionality
обеспечивает полиморфный интерфейс для всех оснасток, которые могут потребляться расширяемым приложением. Учитывая, что рассматриваемый пример является полностью иллюстративным, в интерфейсе определен единственный метод под названием DoIt()
.
Тип
CompanyInfoAttribute
— это специальный атрибут, который может применяться к любому классу, желающему подключиться к контейнеру. Как несложно заметить по определению класса, [CompanyInfо]
позволяет разработчику оснастки указывать общие сведения о месте происхождения компонента.
Удалите стандартный файл
Class1.cs
из проекта CSharpSnapIn
и добавьте новый файл по имени CSharpModule.cs
. Поместите в него следующий код:
using System;
using CommonSnappableTypes;
namespace CSharpSnapIn
{
[CompanyInfo(CompanyName = "FooBar", CompanyUrl = "www.FooBar.com")]
public class CSharpModule : IAppFunctionality
{
void IAppFunctionality.DoIt()
{
Console.WriteLine("You have just used the C# snap-in!");
}
}
}
Обратите внимание на явную реализацию интерфейса
IAppFunctionality
(см. главу 8). Поступать так необязательно; тем не менее, идея заключается в том, что единственной частью системы, которая нуждается в прямом взаимодействии с упомянутым интерфейсным типом, будет размещающее приложение. Благодаря явной реализации интерфейса IAppFunctionality
метод DoIt()
не доступен напрямую из типа CSharpModule
.
Теперь перейдите к проекту
VBSnapIn
. Удалите файл Class1.vb
и добавьте новый файл по имени VBSnapIn.vb
. Код Visual Basic столь же прост:
Imports CommonSnappableTypes
Public Class VBSnapIn
Implements IAppFunctionality
Public Sub DoIt() Implements CommonSnappableTypes.IAppFunctionality.DoIt
Console.WriteLine("You have just used the VB snap in!")
End Sub
End Class
Как видите, применение атрибутов в синтаксисе Visual Basic требует указания угловых скобок (
<>
), а не квадратных ([]
). Кроме того, для реализации интерфейсных типов заданным классом или структурой используется ключевое слово Implements
.
Последним обновляемым проектом является консольное приложение C# (
MyExtendableApp
). После добавления к решению консольного приложения MyExtendableApp
и установки его как стартового проекта добавьте ссылку на проект CommonSnappableTypes
, но не на CSharpSnapIn.dll
или VbSnapIn.dll
. Модифицируйте операторы using
в начале файла Program.cs
, как показано ниже:
using System;
using System.Linq;
using System.Reflection;
using CommonSnappableTypes;
Метод
LoadExternalModule()
выполняет следующие действия:
• динамически загружает в память выбранную сборку;
• определяет, содержит ли сборка типы, реализующие интерфейс
IAppFunctionality
;
• создает экземпляр типа, используя позднее связывание.
Если обнаружен тип, реализующий
IAppFunctionality
, тогда вызывается метод DoIt()
и найденный тип передается методу DisplayCompanyData()
для вывода дополнительной информации о нем посредством рефлексии.
static void LoadExternalModule(string assemblyName)
{
Assembly theSnapInAsm = null;
try
{
// Динамически загрузить выбранную сборку.
theSnapInAsm = Assembly.LoadFrom(assemblyName);
}
catch (Exception ex)
{
// Ошибка при загрузке оснастки
Console.WriteLine($"An error occurred loading the snapin: {ex.Message}");
return;
}
// Получить все совместимые c IAppFunctionality классы в сборке.
var theClassTypes = theSnapInAsm
.GetTypes()
.Where(t => t.IsClass && (t.GetInterface("IAppFunctionality") != null))
.ToList();
if (!theClassTypes.Any())
{
Console.WriteLine("Nothing implements IAppFunctionality!");
// Ни один класс не реализует IAppFunctionality!
}
// Создать объект и вызвать метод DoIt().
foreach (Type t in theClassTypes)
{
/// Использовать позднее связывание для создания экземпляра типа.
IAppFunctionality itfApp =
(IAppFunctionality)
theSnapInAsm.CreateInstance(t.FullName,
true);
itfApp?.DoIt();
// Отобразить информацию о компании.
DisplayCompanyData(t);
}
}
Финальная задача связана с отображением метаданных, предоставляемых атрибутом
[CompanyInfo]
. Создайте метод DisplayCompanyData()
, который принимает параметр System.Туре
:
static void DisplayCompanyData(Type t)
{
// Получить данные [CompanyInfo].
var compInfo = t
.GetCustomAttributes(false)
.Where(ci => (ci is CompanyInfoAttribute));
// Отобразить данные.
foreach (CompanyInfoAttribute c in compInfo)
{
Console.WriteLine($"More info about {c.CompanyName}
can be found at {c.CompanyUrl}");
}
}
Наконец, модифицируйте операторы верхнего уровня следующим образом:
Console.WriteLine("***** Welcome to MyTypeViewer *****");
string typeName = "";
do
{
Console.WriteLine("\nEnter a snapin to load");
// Введите оснастку для загрузки
Console.Write("or enter Q to quit: ");
// или Q для завершения
// Получить имя типа.
typeName = Console.ReadLine();
// Желает ли пользователь завершить работу?
if (typeName.Equals("Q", StringComparison.OrdinalIgnoreCase))
{
break;
}
// Попытаться отобразить тип.
try
{
LoadExternalModule(typeName);
}
catch (Exception ex)
{
// Найти оснастку не удалось.
Console.WriteLine("Sorry, can't find snapin");
}
}
while (true);
На этом создание примера расширяемого приложения завершено. Вы смогли увидеть, что представленные в главе приемы могут оказаться весьма полезными, и их применение не ограничивается только разработчиками инструментов.
Рефлексия является интересным аспектом надежной объектно - ориентированной среды. В мире .NET Core службы рефлексии вращаются вокруг класса
System.Туре
и пространства имен System.Reflection
. Вы видели, что рефлексия — это процесс помещения типа под "увеличительное стекло" во время выполнения с целью выяснения его характеристик и возможностей.
Позднее связывание представляет собой процесс создания экземпляра типа и обращения к его членам без предварительного знания имен членов типа. Позднее связывание часто является прямым результатом динамической загрузки, которая позволяет программным образом загружать сборку .NET Core в память. На примере построения расширяемого приложения было продемонстрировано, что это мощный прием, используемый разработчиками инструментов, а также их потребителями.
Кроме того, в главе была исследована роль программирования на основе атрибутов. Снабжение типов атрибутами приводит к дополнению метаданных лежащей в основе сборки.
В версии .NET 4.0 язык C# получил новое ключевое слово
dynamic
, которое позволяет внедрять в строго типизированный мир безопасности к типам, точек с запятой и фигурных скобок поведение, характерное для сценариев. Используя такую слабую типизацию, можно значительно упростить решение ряда сложных задач написания кода и получить возможность взаимодействия с несколькими динамическими языками (вроде IronRuby и IronPython), которые поддерживают .NET Core.
В настоящей главе вы узнаете о ключевом слове dynamic и о том, как слабо типизированные вызовы отображаются на корректные объекты в памяти с применением исполняющей среды динамического языка (Dynamic Language Runtime — DLR). После освоения служб, предлагаемых средой DLR, вы увидите примеры использования динамических типов для облегчения вызова методов с поздним связыванием (через службы рефлексии) и простого взаимодействия с унаследованными библиотеками СОМ.
На заметку! Не путайте ключевое слово
dynamic
языка C# с концепцией динамической сборки (объясняемой в главе 19). Хотя ключевое слово dynamic
может применяться при построении динамической сборки, все же это две совершенно независимые концепции.
В главе 3 вы ознакомились с ключевым словом
var
, которое позволяет объявлять локальные переменные таким способом, что их действительные типы данных определяются на основе начального присваивания во время компиляции (вспомните, что результат называется неявной типизацией). После того как начальное присваивание выполнено, вы имеете строго типизированную переменную, и любая попытка присвоить ей несовместимое значение приведет к ошибке на этапе компиляции.
Чтобы приступить к исследованию ключевого слова
dynamic
языка С#, создайте новый проект консольного приложения по имени DynamicKeyword
. Добавьте в класс Program
следующий метод и удостоверьтесь, что финальный оператор кода на самом деле генерирует ошибку на этапе компиляции, если убрать символы комментария:
static void ImplicitlyTypedVariable()
{
// Переменная а имеет тип List.
var a = new List {90};
// Этот оператор приведет к ошибке на этапе компиляции!
// a = "Hello";
}
Использование неявной типизации лишь потому, что она возможна, некоторые считают плохим стилем (если известно, что необходима переменная типа
List
, то так и следует ее объявлять). Однако, как было показано в главе 13, неявная типизация удобна в сочетании с LINQ, поскольку многие запросы LINQ возвращают перечисления анонимных классов (через проецирование), которые напрямую объявлять в коде C# невозможно. Тем не менее, даже в таких случаях неявно типизированная переменная фактически будет строго типизированной.
В качестве связанного замечания: в главе 6 упоминалось, что
System.Object
является изначальным родительским классом внутри инфраструктуры .NET Core и может представлять все, что угодно. Опять-таки, объявление переменной типа object
в результате дает строго типизированный фрагмент данных, но то, на что указывает эта переменная в памяти, может отличаться в зависимости от присваиваемой ссылки. Чтобы получить доступ к членам объекта, на который указывает ссылка в памяти, понадобится выполнить явное приведение.
Предположим, что есть простой класс по имени
Person
, в котором определены два автоматических свойства (FirstName
и LastName
), инкапсулирующие данные string
. Взгляните на следующий код:
static void UseObjectVariable()
{
// Пусть имеется класс по имени Person.
object o = new Person() { FirstName = "Mike", LastName = "Larson" };
// Для получения доступа к свойствам Person
.
// переменную о потребуется привести к Person
Console.WriteLine("Person's first name is {0}", ((Person)o).FirstName);
}
А теперь возвратимся к ключевому слову
dynamic
. С высокоуровневой точки значения ключевое слово dynamic
можно трактовать как специализированную форму типа System.Object
— в том смысле, что переменной динамического типа данных может быть присвоено любое значение. На первый взгляд это может привести к серьезной путанице, поскольку теперь получается, что доступны три способа определения данных, внутренний тип которых явно не указан в кодовой базе. Например, следующий метод:
static void PrintThreeStrings()
{
var s1 = "Greetings";
object s2 = "From";
dynamic s3 = "Minneapolis";
Console.WriteLine("s1 is of type: {0}", s1.GetType());
Console.WriteLine("s2 is of type: {0}", s2.GetType());
Console.WriteLine("s3 is of type: {0}", s3.GetType());
}
в случае вызова приведет к такому выводу:
s1 is of type: System.String
s2 is of type: System.String
s3 is of type: System.String
Динамическая переменная и переменная, объявленная неявно или через ссылку на
System.Object
, существенно отличаются тем, что динамическая переменная не является строго типизированной. Выражаясь по-другому, динамические данные не типизированы статически. Для компилятора C# ситуация выглядит так, что элементу данных, объявленному с ключевым словом dynamic
, можно присваивать вообще любое начальное значение, и на протяжении периода его существования взамен начального значения может быть присвоено любое новое (возможно, не связанное) значение. Рассмотрим показанный ниже метод и результирующий вывод:
static void ChangeDynamicDataType()
{
// Объявить одиночный динамический элемент данных по имени t
.
dynamic t = "Hello!";
Console.WriteLine("t is of type: {0}", t.GetType());
t = false;
Console.WriteLine("t is of type: {0}", t.GetType());
t = new List();
Console.WriteLine("t is of type: {0}", t.GetType());
}
Вот вывод:
t is of type: System.String
t is of type: System.Boolean
t is of type: System.Collections.Generic.List`1[System.Int32]
Имейте в виду, что приведенный выше код успешно скомпилировался и дал бы идентичный результат, если бы переменная
t
была объявлена с типом System.Object
. Однако, как вскоре будет показано, ключевое слово dynamic
предлагает много дополнительных возможностей.
Учитывая то, что динамическая переменная способна принимать идентичность любого типа на лету (подобно переменной типа
System.Object
), у вас может возникнуть вопрос о способе обращения к членам такой переменной (свойствам, методам, индексаторам, событиям и т.п.). С точки зрения синтаксиса отличий нет. Нужно просто применить операцию точки к динамической переменной, указать открытый член и предоставить любые аргументы (если они требуются).
Но (и это очень важное "но") допустимость указываемых членов компилятор проверять не будет! Вспомните, что в отличие от переменной, определенной с типом
System.Object
, динамические данные не являются статически типизированными. Вплоть до времени выполнения не будет известно, поддерживают ли вызываемые динамические данные указанный член, переданы ли корректные параметры, правильно ли записано имя члена, и т.д. Таким образом, хотя это может показаться странным, следующий метод благополучно скомпилируется:
static void InvokeMembersOnDynamicData()
{
dynamic textData1 = "Hello";
Console.WriteLine(textData1.ToUpper());
// Здесь можно было бы ожидать ошибки на этапе компиляции!
// Однако все компилируется нормально.
Console.WriteLine(textData1.toupper());
Console.WriteLine(textData1.Foo(10, "ee", DateTime.Now));
}
Обратите внимание, что во втором вызове
WriteLine()
предпринимается попытка обращения к методу по имени toupper()
на динамическом элементе данных (при записи имени метода использовался неправильный регистр символов; оно должно выглядеть как ToUpper()
). Как видите, переменная textData1
имеет тип string
, а потому известно, что у этого типа отсутствует метод с именем, записанным полностью в нижнем регистре. Более того, тип string
определенно не имеет метода по имени Foo()
, который принимает параметры int
, string
и DataTime
!
Тем не менее, компилятор C# ни о каких ошибках не сообщает. Однако если вызвать метод
InvokeMembeгsOnDynamicData()
, то возникнет ошибка времени выполнения с примерно таким сообщением:
Unhandled Exception : Microsoft.CSharp.RuntimeBinder.RuntimeBinderException:
'string' does not contain a definition for 'toupper'
Необработанное исключение: Microsoft.CSharp.RuntimeBinder.
RuntimeBinderException: string не содержит определения для toupper
Другое очевидное отличие между обращением к членам динамических и строго типизированных данных связано с тем, что когда к элементу динамических данных применяется операция точки, ожидаемое средство IntelliSense среды Visual Studio не активизируется. Взамен IDE-среда позволит вводить любое имя члена, какое только может прийти вам на ум.
Отсутствие возможности доступа к средству IntelliSense для динамических данных должно быть понятным. Тем не менее, как вы наверняка помните, это означает необходимость соблюдения предельной аккуратности при наборе кода C# для таких элементов данных. Любая опечатка или символ в неправильном регистре внутри имени члена приведет к ошибке времени выполнения, в частности к генерации исключения типа
RuntimeBinderException
.
Класс
RuntimeBinderException
представляет ошибку, которая будет сгенерирована при попытке обращения к несуществующему члену динамического типа данных (как в случае toupper()
и Foo()
). Та же самая ошибка будет инициирована, если для члена, который существует, указаны некорректные данные параметров.
Поскольку динамические данные настолько изменчивы, любые обращения к членам переменной, объявленной с ключевым словом
dynamic
, могут быть помещены внутрь подходящего блока try/catch
для элегантной обработки ошибок:
static void InvokeMembersOnDynamicData()
{
dynamic textData1 = "Hello";
try
{
Console.WriteLine(textData1.ToUpper());
Console.WriteLine(textData1.toupper());
Console.WriteLine(textData1.Foo(10, "ee", DateTime.Now));
}
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException ex)
{
Console.WriteLine(ex.Message);
}
}
Если вызвать метод
InvokeMembersOnDynamicData()
снова, то можно заметить, что вызов ToUpper()
(обратите внимание на заглавные буквы "Т" и "U") работает корректно, но затем на консоль выводится сообщение об ошибке:
HELLO
'string' does not contain a definition for 'toupper'
string не содержит определение для toupper
Конечно, процесс помещения всех динамических обращений к методам в блоки
try/catch
довольно утомителен. Если вы тщательно следите за написанием кода и передачей параметров, тогда поступать так необязательно. Однако перехват исключений удобен, когда вы заранее не знаете, присутствует ли интересующий член в целевом типе.
Вспомните, что неявно типизированные данные (объявленные с ключевым словом
var
) возможны только для локальных переменных в области действия члена. Ключевое слово var
никогда не может использоваться с возвращаемым значением, параметром или членом класса/структуры. Тем не менее, это не касается ключевого слова dynamic
. Взгляните на следующее определение класса:
namespace DynamicKeyword
{
class VeryDynamicClass
{
// Динамическое поле.
private static dynamic _myDynamicField;
// Динамическое свойство.
public dynamic DynamicProperty { get; set; }
// Динамический тип возврата и динамический тип параметра.
public dynamic DynamicMethod(dynamic dynamicParam)
{
// Динамическая локальная переменная.
dynamic dynamicLocalVar = "Local variable";
int myInt = 10;
if (dynamicParam is int)
{
return dynamicLocalVar;
}
else
{
return myInt;
}
}
}
}
Теперь обращаться к открытым членам можно было бы ожидаемым образом; однако при работе с динамическими методами и свойствами нет полной уверенности в том, каким будет тип данных! По правде говоря, определение
VeryDynamicClass
может оказаться не особенно полезным в реальном приложении, но оно иллюстрирует область, где допускается применять ключевое слово dynamic
.
Невзирая на то, что с использованием ключевого слова
dynamic
можно определять разнообразные сущности, с ним связаны некоторые ограничения. Хотя они не настолько впечатляющие, следует помнить, что элементы динамических данных при вызове метода не могут применять лямбда-выражения или анонимные методы С#. Например, показанный ниже код всегда будет давать в результате ошибки, даже если целевой метод на самом деле принимает параметр типа делегата, который в свою очередь принимает значение string
и возвращает void
:
dynamic a = GetDynamicObject();
// Ошибка! Методы на динамических данных не могут использовать
// лямбда-выражения!
a.Method(arg => Console.WriteLine(arg));
Чтобы обойти упомянутое ограничение, понадобится работать с лежащим в основе делегатом напрямую, используя приемы из главы 12. Еще одно ограничение заключается в том, что динамический элемент данных не может воспринимать расширяющие методы (см. главу 11). К сожалению, сказанное касается также всех расширяющих методов из API-интерфейсов LINQ. Следовательно, переменная, объявленная с ключевым словом
dynamic
, имеет ограниченное применение в рамках LINQ to Objects и других технологий LINQ:
dynamic a = GetDynamicObject();
// Ошибка! Динамические данные не могут найти расширяющий метод Select()!
var data = from d in a select d;
С учетом того, что динамические данные не являются строго типизированными, не проверяются на этапе компиляции, не имеют возможности запускать средство IntelliSense и не могут быть целью запроса LINQ, совершенно корректно предположить, что применение ключевого слова
dynamic
лишь по причине его существования представляет собой плохую практику программирования.
Тем не менее, в редких обстоятельствах ключевое слово
dynamic
может радикально сократить объем вводимого вручную кода. В частности, при построении приложения .NET Core, в котором интенсивно используется позднее связывание (через рефлексию), ключевое слово dynamic
может сэкономить время на наборе кода. Аналогично при разработке приложения .NET Core, которое должно взаимодействовать с унаследованными библиотеками СОМ (вроде тех, что входят в состав продуктов Microsoft Office), за счет использования ключевого слова dynamic
можно значительно упростить кодовую базу. В качестве финального примера можно привести веб-приложения, построенные с применением ASP.NET Core: они часто используют тип ViewBag
, к которому также допускается производить доступ в упрощенной манере с помощью ключевого слова dynamic
.
На заметку! Взаимодействие с СОМ является строго парадигмой Windows и исключает межплатформенные возможности из вашего приложения.
Как с любым "сокращением", прежде чем его использовать, необходимо взвесить все "за" и "против". Применение ключевого слова
dynamic
— компромисс между краткостью кода и безопасностью к типам. В то время как C# в своей основе является строго типизированным языком, динамическое поведение можно задействовать (или нет) от вызова к вызову. Всегда помните, что использовать ключевое слово dynamic
необязательно. Тот же самый конечный результат можно получить, написав альтернативный код вручную (правда, обычно намного большего объема).
Теперь, когда вы лучше понимаете сущность "динамических данных", давайте посмотрим, как их обрабатывать. Начиная с версии .NET 4.0, общеязыковая исполняющая среда (Common Language Runtime — CLR) получила дополняющую среду времени выполнения, которая называется исполняющей средой динамического языка (Dynamic Language Runtime — DLR). Концепция "динамической исполняющей среды" определенно не нова. На самом деле ее много лет используют такие языки программирования, как JavaScript, LISP, Ruby и Python. Выражаясь кратко, динамическая исполняющая среда предоставляет динамическим языкам возможность обнаруживать типы полностью во время выполнения без каких-либо проверок на этапе компиляции.
На заметку! Хотя большая часть функциональных средств среды DLR была перенесена в .NET Core (начиная с версии 3.0), паритет в плане функциональности между DLR в .NET Core 5 и .NET 4.8 так и не был достигнут.
Если у вас есть опыт работы со строго типизированными языками (включая C# без динамических типов), тогда идея такой исполняющей среды может показаться неподходящей. В конце концов, вы обычно хотите выявлять ошибки на этапе компиляции, а не во время выполнения, когда только возможно. Тем не менее, динамические языки и исполняющие среды предлагают ряд интересных средств, включая перечисленные ниже.
• Чрезвычайно гибкая кодовая база. Можно проводить рефакторинг кода, не внося многочисленных изменений в типы данных.
• Простой способ взаимодействия с разнообразными типами объектов, которые построены на разных платформах и языках программирования.
• Способ добавления или удаления членов типа в памяти во время выполнения.
Одна из задач среды DLR заключается в том, чтобы позволить различным динамическим языкам работать с исполняющей средой .NET Core и предоставлять им возможность взаимодействия с другим кодом .NET Core. Двумя популярными динамическими языками, которые используют DLR, являются IronPython и IronRuby. Указанные языки находятся в "динамической вселенной", где типы определяются целиком во время выполнения. К тому же данные языки имеют доступ ко всему богатству библиотек базовых классов .NET Core. А еще лучше то, что благодаря наличию ключевого слова dynamic их кодовые базы могут взаимодействовать с языком C# (и наоборот).
На заметку! В настоящей главе вопросы применения среды DLR для интеграции с динамическими языками не обсуждаются.
Для описания динамического вызова в нейтральных терминах среда DLR использует деревья выражений. Например, взгляните на следующий код С#:
dynamic d = GetSomeData();
d.SuperMethod(12);
В приведенном выше примере среда DLR автоматически построит дерево выражения, которое по существу гласит: "Вызвать метод по имени
SuperMethod()
на объекте d
, передав число 12
в качестве аргумента". Затем эта информация (формально называемая полезной нагрузкой) передается корректному связывателю времени выполнения, который может быть динамическим связывателем C# или (как вскоре будет объяснено) даже унаследованным объектом СОМ.
Далее запрос отображается на необходимую структуру вызовов для целевого объекта. Деревья выражений обладают одной замечательной характеристикой (помимо того, что их не приходится создавать вручную): они позволяют писать фиксированный оператор кода C# и не беспокоиться о том, какой будет действительная цель.
Как уже объяснялось, среда DLR будет передавать деревья выражений целевому объекту; тем не менее, на этот процесс отправки влияет несколько факторов. Если динамический тип данных указывает в памяти на объект СОМ, то дерево выражения отправляется реализации низкоуровневого интерфейса СОМ по имени
IDispatch
. Как вам может быть известно, упомянутый интерфейс представляет собой способ, которым СОМ внедряет собственный набор динамических служб. Однако объекты СОМ можно использовать в приложении .NET Core без применения DLR или ключевого слова dynamic
языка С#. Тем не менее, такой подход (как вы увидите) сопряжен с написанием более сложного кода на С#.
Если динамические данные не указывают на объект СОМ, тогда дерево выражения может быть передано объекту, реализующему интерфейс
IDynamicObject
. Указанный интерфейс используется "за кулисами", чтобы позволить языку вроде IronRuby принимать дерево выражения DLR и отображать его на специфические средства языка Ruby.
Наконец, если динамические данные указывают на объект, который не является объектом СОМ и не реализует интерфейс
IDynamicObject
, то это нормальный повседневный объект .NET Core. В таком случае дерево выражения передается на обработку связывателю исполняющей среды С#. Процесс отображения дерева выражений на специфические средства платформы .NET Core вовлекает в дело службы рефлексии.
После того как дерево выражения обработано определенным связывателем, динамические данные преобразуются в реальный тип данных в памяти, после чего вызывается корректный метод со всеми необходимыми параметрами. Теперь давайте рассмотрим несколько практических применений DLR, начав с упрощения вызовов .NET Core с поздним связыванием.
Одним из случаев, когда имеет смысл использовать ключевое слово
dynamic
, может быть работа со службами рефлексии, а именно — вызов методов с поздним связыванием. В главе 17 приводилось несколько примеров, когда вызовы методов такого рода могут быть полезными — чаще всего при построении расширяемого приложения. Там вы узнали, как применять метод Activator.Createlnstance()
для создания объекта типа, о котором ничего не известно на этапе компиляции (помимо его отображаемого имени). Затем с помощью типов из пространства имен System.Reflection
можно обращаться к членам объекта через механизм позднего связывания. Вспомните показанный ниже пример из главы 17:
static void CreateUsingLateBinding(Assembly asm)
{
try
{
// Получить метаданные для типа MiniVan.
Type miniVan = asm.GetType("CarLibrary.MiniVan");
// Создать экземпляр MiniVan на лету.
object obj = Activator.CreateInstance(miniVan);
// Получить информацию о TurboBoost.
MethodInfo mi = miniVan.GetMethod("TurboBoost");
// Вызвать метод (null означает отсутствие параметров).
mi.Invoke(obj, null);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
В то время как приведенный код функционирует ожидаемым образом, нельзя не отметить его некоторую громоздкость. Вы должны вручную работать с классом
MethodInfo
, вручную запрашивать метаданные и т.д. В следующей версии того же метода используется ключевое слово dynamic
и среда DLR:
static void InvokeMethodWithDynamicKeyword(Assembly asm)
{
try
{
// Получить метаданные для типа Minivan.
Type miniVan = asm.GetType("CarLibrary.MiniVan");
// Создать экземпляр MiniVan на лету и вызвать метод.
dynamic obj = Activator.CreateInstance(miniVan);
obj.TurboBoost();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
За счет объявления переменной
obj
с ключевым словом dynamic
вся рутинная работа, связанная с рефлексией, перекладывается на DLR.
Полезность среды DLR становится еще более очевидной, когда нужно выполнять вызовы методов с поздним связыванием, которые принимают параметры. В случае применения "многословных" обращений к рефлексии аргументы нуждаются в упаковке внутрь массива экземпляров
object
, который передается методу Invoke()
класса MethodInfo
.
Чтобы проиллюстрировать использование, создайте новый проект консольного приложения C# по имени
LateBindingWithDynamic
. Добавьте к решению проект библиотеки классов под названием MathLibrary
. Переименуйте первоначальный файл Class1.cs
в проекте MathLibrary
на SimplaMath.cs
и реализуйте класс, как показано ниже:
namespace MathLibrary
{
public class SimpleMath
{
public int Add(int x, int y)
{
return x + y;
}
}
}
Модифицируйте содержимое файла
MathLibrary.csproj
следующим образом (чтобы скомпилированная сборка копировалась в целевой каталог LateBindingWithDynamic
):
"copy $(TargetPath) $(SolutionDir)LateBindingWithDynamic\$(OutDir)
$(TargetFileName) /Y
copy $(TargetPath)
$(SolutionDir)LateBindingWithDynamic\
$(TargetFileName) /Y" />
На заметку! Если вы не знакомы с событиями при компиляции, тогда ищите подробные сведения в главе 17.
Теперь возвратитесь к проекту
LateBindingWithDynamic
и импортируйте пространства имен System.Reflection
и Microsoft.CSharp.RuntimeBinder
в файл Program.cs
. Добавьте в класс Program
следующий метод, который вызывает метод Add()
с применением типичных обращений к API-интерфейсу рефлексии:
static void AddWithReflection()
{
Assembly asm = Assembly.LoadFrom("MathLibrary");
try
{
// Получить метаданные для типа SimpleMath.
Type math = asm.GetType("MathLibrary.SimpleMath");
// Создать объект SimpleMath на лету.
object obj = Activator.CreateInstance(math);
// Получить информацию о методе Add().
MethodInfo mi = math.GetMethod("Add");
// Вызвать метод (с параметрами).
object[] args = { 10, 70 };
Console.WriteLine("Result is: {0}", mi.Invoke(obj, args));
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
Ниже показано, как можно упростить предыдущую логику, используя ключевое слово
dynamic
:
private static void AddWithDynamic()
{
Assembly asm = Assembly.LoadFrom("MathLibrary");
try
{
// Получить метаданные для типа SimpleMath.
Type math = asm.GetType("MathLibrary.SimpleMath");
// Создать объект SimpleMath на лету.
dynamic obj = Activator.CreateInstance(math);
// Обратите внимание, насколько легко теперь вызывать метод Add().
Console.WriteLine("Result is: {0}", obj.Add(10, 70));
}
catch (RuntimeBinderException ex)
{
Console.WriteLine(ex.Message);
}
}
В результате вызова обоих методов получается идентичный вывод. Однако в случае применения ключевого слова dynamic сокращается объем кода. Благодаря динамически определяемым данным вам больше не придется вручную упаковывать аргументы внутрь массива экземпляров
object
, запрашивать метаданные сборки либо иметь дело с другими деталями подобного рода. При построении приложения, в котором интенсивно используется динамическая загрузка и позднее связывание, экономия на кодировании со временем становится еще более ощутимой.
Давайте рассмотрим еще один полезный сценарий для ключевого слова
dynamic
в рамках проекта взаимодействия с СОМ. Если у вас нет опыта разработки для СОМ, то имейте в виду, что скомпилированная библиотека СОМ содержит метаданные подобно библиотеке .NET Core, но ее формат совершенно другой. По указанной причине, когда программа .NET Core нуждается во взаимодействии с объектом СОМ, первым делом потребуется сгенерировать так называемую сборку взаимодействия (описанную ниже). Задача довольно проста.
На заметку! Если вы не устанавливали индивидуальный компонент Visual Studio Tools for Office (Инструменты Visual Studio для Office) или рабочую нагрузку Office/SharePoint development (Разработка для Office/SharePoint), то для проработки примеров в текущем разделе вам придется это сделать. Можете запустить программу установки и выбрать недостающий компонент или воспользоваться средством быстрого запуска Visual Studio (<Ctrl+Q>). Введите Visual Studio Tools for Office в поле быстрого запуска и выберите вариант Install (Установить).
Для начала создайте новый проект консольного приложения по имени
ExportDataToOfficeApp
, откройте диалоговое окно Add COM Reference (Добавление ссылки СОМ), перейдите на вкладку СОМ и отыщите желаемую библиотеку СОМ, которой в данном случае является Microsoft Excel 16.0 Object Library (рис. 18.1).
После выбора СОМ-библиотеки IDE-среда отреагирует генерацией новой сборки, которая включает описания .NET Core метаданных СОМ. Формально она называется сборкой взаимодействия и не содержит какого-либо кода реализации кроме небольшой порции кода, который помогает транслировать события СОМ в события .NET Core. Тем не менее, сборки взаимодействия полезны тем, что защищают кодовую базу .NET Core от сложностей внутреннего механизма СОМ.
В коде C# можно напрямую работать со сборкой взаимодействия, которая отображает типы данных .NET Core на типы СОМ и наоборот. "За кулисами" данные маршализируются между приложениями .NET Core и СОМ с применением вызываемой оболочки времени выполнения (runtime callable wrapper — RCW), по существу являющейся динамически сгенерированным посредником. Такой посредник RCW будет маршализировать и трансформировать типы данных .NET Core в типы СОМ и отображать любые возвращаемые значения СОМ на их эквиваленты .NET Core.
Многие библиотеки СОМ, созданные поставщиками библиотек СОМ (вроде библиотек Microsoft СОМ, обеспечивающих доступ к объектной модели продуктов Microsoft Office), предоставляют "официальную" сборку взаимодействия, которая называется основной сборкой взаимодействия (primary interop assembly — PIA). Сборки PIA — это оптимизированные сборки взаимодействия, которые приводят в порядок (и возможно расширяют) код, обычно генерируемый при добавлении ссылки на библиотеку СОМ с помощью диалогового окна Add Reference.
После добавления ссылки на библиотеку Microsoft Excel 16.0 Object Library просмотрите проект в окне Solution Explorer. Внутри узла Dependencies (Зависимости) вы увидите новый узел (СОМ) с элементом по имени
Interop.Microsoft.Office.Interop.Excel
. Это сгенерированный файл взаимодействия.
До выхода версии .NET 4.0, когда приложение C# задействовало библиотеку СОМ (через PIA или нет), на клиентской машине необходимо было обеспечить наличие копии сборки взаимодействия. Помимо увеличения размера установочного пакета приложения сценарий установки должен был также проверять, присутствуют ли сборки PIA, и если нет, тогда устанавливать их копии в глобальный кеш сборок (GAC).
На заметку! Глобальный кеш сборок был центральным хранилищем для сборок .NET Framework. В .NET Core он больше не используется.
Однако в .NET 4.0 и последующих версиях данные взаимодействия теперь можно встраивать прямо в скомпилированное приложение. В таком случае поставлять копию сборки взаимодействия вместе с приложением .NET Core больше не понадобится, т.к. все необходимые метаданные взаимодействия жестко закодированы в приложении .NET. В .NET Core встраивание сборки PIA является обязательным.
Чтобы встроить сборку PIA в среде Visual Studio, разверните узел Dependencies внутри узла проекта, разверните узел СОМ, щелкните правой кнопкой мыши на элементе
Interop.Microsoft.Office.Interop.Excel
и выберите в контекстном меню пункт Properties (Свойства). В диалоговом окне Properties (Свойства) выберите в раскрывающемся списке Embed Interop Types (Встраивать типы взаимодействия) пункт Yes (Да), как показано на рис. 18.2.
Для изменения свойства посредством файла проекта добавьте узел
true
:
00020813-0000-0000-c000-000000000046
1
9
tlbimp
0
false
true
Компилятор C# будет включать только те части библиотеки взаимодействия, которые вы используете. Таким образом, даже если реальная библиотека взаимодействия содержит описания .NET Core сотен объектов СОМ, в приложение попадет только подмножество определений, которые действительно применяются в написанном коде С#. Помимо сокращения размера приложения, поставляемого клиенту, упрощается и процесс установки, т.к. не придется устанавливать сборки PIA, которые отсутствуют на целевой машине.
Многие библиотеки СОМ определяют методы, принимающие необязательные аргументы, которые вплоть до выхода .NET 3.5 в языке C# не поддерживались. Это требовало указания значения
Type.Missing
для каждого вхождения необязательного аргумента. К счастью, в .NET 3.5 и последующих версиях (включая .NET Core) значение Type.Missing
вставляется на этапе компиляции, если не указано какое-то специфическое значение.
В качестве связанного замечания: многие методы СОМ поддерживают именованные аргументы, которые, как объяснялось в главе 4, позволяют передавать значения членам в любом порядке. Учитывая наличие поддержки той же самой возможности в языке С#, допускается просто "пропускать" необязательные аргументы, которые неважны, и устанавливать только те из них, которые нужны в текущий момент.
Еще одна распространенная сложность взаимодействия с СОМ была связана с тем фактом, что многие методы СОМ проектировались так, чтобы принимать и возвращать специфический тип данных по имени
Variant
. Во многом похоже на ключевое слово dynamic
языка С#, типу данных Variant
может быть присвоен на лету любой тип данных СОМ (строка, ссылка на интерфейс, числовое значение и т.д.). До появления ключевого слова dynamic передача и прием элементов данных типа Variant
требовали немалых ухищрений, обычно связанных с многочисленными операциями приведения.
Когда свойство
EmbedlnteropTypes
установлено в true
, все COM-типы Variant
автоматически отображаются на динамические данные. В итоге не только сокращается потребность в паразитных операциях приведения при работе с типами данных Variant
, но также еще больше скрываются некоторые сложности, присущие СОМ, вроде работы с индексаторами СОМ.
Дополнительной сложностью при работе с взаимодействием с СОМ и .NET 5 является отсутствие поддержки на этапе компиляции и во время выполнения. Версия MSBuild в .NET 5 не способна распознавать библиотеки взаимодействия, поэтому проекты .NET Core, в которых задействовано взаимодействие с СОМ, не могут компилироваться с применением интерфейса командной строки .NET Core. Они должны компилироваться с использованием Visual Studio, и скомпилированный исполняющий файл можно будет запускать вполне ожидаемым способом.
Чтобы продемонстрировать, каким образом необязательные аргументы, именованные аргументы и ключевое слово
dynamic
совместно способствуют упрощению взаимодействия с СОМ, будет построено приложение, в котором применяется объектная модель Microsoft Office. Добавьте новый файл класса по имени Car.cs
, содержащий такой код:
namespace ExportDataToOfficeApp
{
public class Car
{
public string Make { get; set; }
public string Color { get; set; }
public string PetName { get; set; }
}
}
Поместите в начало файла
Program.cs
следующие операторы using
:
using System;
using System.Collections.Generic;
using System.Reflection;
using Excel = Microsoft.Office.Interop.Excel;
using ExportDataToOfficeApp;
Обратите внимание на псевдоним
Excel
для пространства имен Microsoft.Office.Interop.Excel
. Хотя при взаимодействии с библиотеками СОМ псевдоним определять не обязательно, это обеспечивает наличие более короткого квалификатора для всех импортированных объектов СОМ. Он не только снижает объем набираемого кода, но также разрешает проблемы, когда объекты СОМ имеют имена, конфликтующие с именами типов .NET Core.
Далее создайте список записей
Car
в операторах верхнего уровня внутри файла Program.cs
:
// Создать псевдоним для объектной модели Excel.
using Excel = Microsoft.Office.Interop.Excel;
Next, create a list of Car records in the top-level statements in Program.cs:
List carsInStock = new List
{
new Car {Color="Green", Make="VW", PetName="Mary"},
new Car {Color="Red", Make="Saab", PetName="Mel"},
new Car {Color="Black", Make="Ford", PetName="Hank"},
new Car {Color="Yellow", Make="BMW", PetName="Davie"}
}
Поскольку вы импортировали библиотеку СОМ с использованием Visual Studio, сборка PIA автоматически сконфигурирована так, что используемые метаданные будут встраиваться в приложение .NET Core. Таким образом, все типы данных
Variant
из СОМ реализуются как типы данных dynamic
. Взгляните на показанную ниже реализацию метода ExportToExcel()
:
void ExportToExcel(List carsInStock)
{
// Загрузить Excel и затем создать новую пустую рабочую книгу.
Excel.Application excelApp = new Excel.Application();
excelApp.Workbooks.Add();
// В этом примере используется единственный рабочий лист.
Excel._Worksheet workSheet = (Excel._Worksheet)excelApp.ActiveSheet;
// Установить заголовки столбцов в ячейках.
workSheet.Cells[1, "A"] = "Make";
workSheet.Cells[1, "B"] = "Color";
workSheet.Cells[1, "C"] = "Pet Name";
// Сопоставить все данные из List с ячейками электронной таблицы.
int row = 1;
foreach (Car c in carsInStock)
{
row++;
workSheet.Cells[row, "A"] = c.Make;
workSheet.Cells[row, "B"] = c.Color;
workSheet.Cells[row, "C"] = c.PetName;
}
// Придать симпатичный вид табличным данным.
workSheet.Range["A1"].AutoFormat
(Excel.XlRangeAutoFormat.xlRangeAutoFormatClassic2);
// Сохранить файл, завершить работу Excel и отобразить сообщение пользователю.
workSheet.SaveAs($@"{Environment.CurrentDirectory}\Inventory.xlsx");
excelApp.Quit();
Console.WriteLine("The Inventory.xslx file has been saved to your app folder");
// Файл Inventory.xslx сохранен в папке приложения.
}
Метод
ExportToExcel()
начинается с загрузки приложения Excel
в память; однако на рабочем столе оно не отобразится. В данном приложении нас интересует только работа с внутренней объектной моделью Excel
. Тем не менее, если необходимо отобразить пользовательский интерфейс Excel
, тогда метод понадобится дополнить следующим кодом:
static void ExportToExcel(List carsInStock)
{
// Загрузить Excel и затем создать новую пустую рабочую книгу.
Excel.Application excelApp = new Excel.Application();
// Сделать пользовательский интерфейс Excel видимым на рабочем столе.
excelApp.Visible = true;
...
}
После создания пустого рабочего листа добавляются три столбца, именованные в соответствии со свойствами класса
Car
. Затем ячейки наполняются данными List
, и файл сохраняется с жестко закодированным именем Inventory.xlsx
.
Если вы запустите приложение, то сможете затем открыть файл
Inventory.xlsx
, который будет сохранен в подкаталоге \bin\Debug\net5.0
вашего проекта.
Хотя не похоже, что в предыдущем коде использовались какие-либо динамические данные, имейте в виду, что среда DLR оказала значительную помощь. Без среды DLR код пришлось записывать примерно так:
static void ExportToExcelManual(List carsInStock)
{
Excel.Application excelApp = new Excel.Application();
// Потребуется пометить пропущенные параметры!
excelApp.Workbooks.Add(Type.Missing);
// Потребуется привести объект Object к _Worksheet!
Excel._Worksheet workSheet =
(Excel._Worksheet)excelApp.ActiveSheet;
// Потребуется привести каждый объект Object к Range
// и затем обратиться к низкоуровневому свойству Value2!
((Excel.Range)excelApp.Cells[1, "A"]).Value2 = "Make";
((Excel.Range)excelApp.Cells[1, "B"]).Value2 = "Color";
((Excel.Range)excelApp.Cells[1, "C"]).Value2 = "Pet Name";
int row = 1;
foreach (Car c in carsInStock)
{
row++;
// Потребуется привести каждый объект Object к Range
// и затем обратиться к низкоуровневому свойству Value2!
((Excel.Range)workSheet.Cells[row, "A"]).Value2 = c.Make;
((Excel.Range)workSheet.Cells[row, "B"]).Value2 = c.Color;
((Excel.Range)workSheet.Cells[row, "C"]).Value2 = c.PetName;
}
// Потребуется вызвать метод get _ Range()
// с указанием всех пропущенных аргументов!
excelApp.get_Range("A1", Type.Missing).AutoFormat(
Excel.XlRangeAutoFormat.xlRangeAutoFormatClassic2,
Type.Missing, Type.Missing, Type.Missing,
Type.Missing, Type.Missing, Type.Missing);
// Потребуется указать все пропущенные необязательные аргументы!
workSheet.SaveAs(
$@"{Environment.CurrentDirectory}\InventoryManual.xlsx",
Type.Missing, Type.Missing, Type.Missing,
Type.Missing, Type.Missing, Type.Missing,
Type.Missing, Type.Missing, Type.Missing);
excelApp.Quit();
Console.WriteLine("The InventoryManual.xslx file has been saved to your app folder");
// Файл Inventory.xslx сохранен в папке приложения.
}
На этом рассмотрение ключевого слова
dynamic
языка C# и среды DLR завершено. Вы увидели, насколько данные средства способны упростить сложные задачи программирования, и (что возможно даже важнее) уяснили сопутствующие компромиссы. Делая выбор в пользу динамических данных, вы теряете изрядную часть безопасности типов, и ваша кодовая база предрасположена к намного большему числу ошибок времени выполнения.
В то время как о среде DLR можно еще рассказать многое, основное внимание в главе было сосредоточено на темах, практичных и полезных при повседневном программировании. Если вы хотите изучить расширенные средства DLR, такие как интеграция с языками написания сценариев, тогда обратитесь в документацию по .NET Core (начните с поиска темы "Dynamic Language Runtime Overview" ("Обзор исполняющей среды динамического языка")).
Ключевое слово
dynamic
позволяет определять данные, идентичность которых не известна вплоть до времени выполнения. При обработке исполняющей средой динамического языка (DLR) автоматически создаваемое "дерево выражения" передается подходящему связывателю динамического языка, а полезная нагрузка будет распакована и отправлена правильному члену объекта.
С применением динамических данных и среды DLR сложные задачи программирования на C# могут быть радикально упрощены, особенно действие по включению библиотек СОМ в состав приложений .NET Core. Было показано, что это предлагает несколько дальнейших упрощений взаимодействия с СОМ (которые не имеют отношения к динамическим данным), в том числе встраивание данных взаимодействия СОМ в разрабатываемые приложения, необязательные и именованные аргументы.
Хотя все рассмотренные средства определенно могут упростить код, всегда помните о том, что динамические данные существенно снижают безопасность к типам в коде C# и открывают возможности для возникновения ошибок времени выполнения. Тщательно взвешивайте все "за" и "против" использования динамических данных в своих проектах C# и надлежащим образом тестируйте их!
При построении полномасштабного приложения .NET Core вы почти наверняка будете использовать C# (или другой управляемый язык, такой как Visual Basic) из-за присущей ему продуктивности и простоты применения. Однако в начале книги было показано, что роль управляемого компилятора заключается в трансляции файлов кода
*.cs
в код CIL, метаданные типов и манифест сборки. Как выяснилось, CIL представляет собой полноценный язык программирования .NET Core, который имеет собственный синтаксис, семантику и компилятор (ilasm.ехе
).
В текущей главе будет предложен краткий экскурс по родному языку платформы .NET Core. Здесь вы узнаете о различиях между директивой, атрибутом и кодом операции CIL. Затем вы ознакомитесь с ролью возвратного проектирования сборок .NET Core и разнообразных инструментов программирования на CIL. Остаток главы посвящен основам определения пространств имен, типов и членов с использованием грамматики CIL. В завершение главы исследуется роль пространства имен
System.Reflection.Emit
и объясняется, как можно динамически конструировать сборки (с помощью инструкций CIL) во время выполнения.
Конечно, необходимость работать с низкоуровневым кодом CIL на повседневной основе будет возникать только у очень немногих программистов. Плава начинается с описания причин, по которым изучение синтаксиса и семантики такого языка .NET Core может оказаться полезным.
Язык CIL является истинным родным языком платформы .NET Core. При построении сборки .NET с помощью выбранного управляемого языка (С#, VB, F# и т.д.) соответствующий компилятор транслирует исходный код в инструкции CIL. Подобно любому языку программирования CIL предлагает многочисленные лексемы, связанные со структурированием и реализацией. Поскольку CIL представляет собой просто еще один язык программирования .NET Core, не должен вызывать удивление тот факт, что сборки .NET Core можно создавать прямо на CIL и компилировать их посредством компилятора CIL (
ilasm.exe
).
На заметку! Как было указано в главе 1, ни
ildasm.exe
, ни ilasm.exe
не поставляется вместе с исполняющей средой .NET 5. Получить эти инструменты можно двумя способами. Первый способ — скомпилировать .NET 5 Runtime из исходного кода, находящегося по ссылке https://github.com/dotnet/runtime
. Второй и более простой способ загрузить желаемую версию из www.nuget.org
. Инструмент ildasm.exe
в хранилище NuGet доступен по ссылке https://www.nuget.org/packages/Microsoft.NETCore.ILDAsm/
, а ilasm.exe
— по ссылке https://www.nuget.org/packages/Microsoft.NETCore.lLAsm/
. Убедитесь в том, что выбрали корректную версию (для данной книги необходима версия 5.0.0 или выше). Добавьте NuGet-пакеты ILDasm
и lLAsm
в свой проект с помощью следующих команд:
dotnet add package Microsoft.NETCore.ILDAsm --version 5.0.0
dotnet add package Microsoft.NETCore.ILAsm --version 5.0.0
Команды на самом деле не добавляют
ildasm.exe
или ilasm.exe
в ваш проект, а помещают их в папку пакетов (в среде Windows):
%userprofile%\.nuget\packages\microsoft.netcore.ilasm\5.0.0\runtimes\native\
%userprofile%\.nuget\packages\microsoft.netcore.ildasm\5.0.0\runtimes\native\
Кроме того, оба инструмента версии 5.0.0 включены в папку
Chapter_19
внутри хранилища GitHub для настоящей книги.
Хотя и верно утверждение о том, что построением полного приложения .NET Core прямо на CIL занимаются лишь немногие программисты (если вообще такие есть), изучение этого языка все равно является чрезвычайно интересным занятием. Попросту говоря, чем лучше вы понимаете грамматику CIL, тем больше способны погрузиться в мир расширенной разработки приложений .NET Core. Обратившись к конкретным примерам, можно утверждать, что разработчики, разбирающиеся в CIL, обладают следующими навыками.
• Умеют дизассемблировать существующую сборку .NET Core, редактировать код CIL в ней и заново компилировать модифицированную кодовую базу в обновленный двоичный файл .NET Core. Скажем, некоторые сценарии могут требовать изменения кода CIL для взаимодействия с расширенными средствами СОМ.
• Умеют строить динамические сборки с применением пространства имен
System.Reflection.Emit
. Данный API-интерфейс позволяет генерировать в памяти сборку .NET Core, которая дополнительно может быть сохранена на диск. Это полезный прием для разработчиков инструментов, которым необходимо генерировать сборки на лету.
• Понимают аспекты CTS, которые не поддерживаются высокоуровневыми управляемыми языками, но существуют на уровне CIL. На самом деле CIL является единственным языком .NET Core, который позволяет получать доступ ко всем аспектам CTS. Например, за счет использования низкоуровневого кода CIL появляется возможность определения членов и полей глобального уровня (которые не разрешены в С#).
Ради полной ясности нужно еще раз подчеркнуть, что овладеть мастерством работы с языком C# и библиотеками базовых классов .NET Core можно и без изучения деталей кода CIL. Во многих отношениях знание CIL аналогично знанию языка ассемблера программистом на С (и C++).Те, кто разбирается в низкоуровневых деталях, способны создавать более совершенные решения поставленных задач и глубже понимают лежащую в основе среду программирования (и выполнения). Словом, если вы готовы принять вызов, тогда давайте приступим к исследованию внутренних деталей CIL.
На заметку! Имейте в виду, что эта глава не планировалась быть всеобъемлющим руководством по синтаксису и семантике CIL.
Когда вы начинаете изучение низкоуровневых языков, таких как CIL, то гарантированно встретите новые (и часто пугающие) названия для знакомых концепций. Например, к этому моменту приведенный ниже набор элементов вы почти наверняка посчитаете ключевыми словами языка C# (и это правильно):
{new, public, this, base, get, set, explicit, unsafe, enum, operator, partial}
Тем не менее, внимательнее присмотревшись к элементам набора, вы сможете заметить, что хотя каждый из них действительно является ключевым словом С#, он имеет радикально отличающуюся семантику Скажем, ключевое слово
enum
определяет производный от System.Enum
тип, а ключевые слова this
и base
позволяют ссылаться на текущий объект и его родительский класс. Ключевое слово unsafe
применяется для установления блока кода, который не может напрямую отслеживаться средой CLR, а ключевое слово operator
дает возможность создать скрытый (специально именованный) метод, который будет вызываться, когда используется специфическая операция C# (такая как знак "плюс").
По разительному контрасту с высокоуровневым языком вроде C# в CIL не просто определен общий набор ключевых слов сам по себе. Напротив, набор лексем, распознаваемых компилятором CIL, подразделяется на следующие три обширные категории, основываясь на их семантике:
• директивы CIL;
• атрибуты CIL;
• коды операций CIL.
Лексемы CIL каждой категории выражаются с применением отдельного синтаксиса и комбинируются для построения допустимой сборки .NET Core.
Прежде всего, существует набор хорошо известных лексем CIL, которые используются для описания общей структуры сборки .NET Core. Такие лексемы называются директивами. Директивы CIL позволяют информировать компилятор CIL о том, каким образом определять пространства имен, типы и члены, которые будут заполнять сборку.
Синтаксически директивы представляются с применением префикса в виде точки (
.
), например, .namespace
, .class
, .publickeytoken
, .override
, .method
, .assembly
и т.д. Таким образом, если в файле с расширением *.il
(общепринятое расширение для файлов кода CIL) указана одна директива .namespace
и три директивы .class
, то компилятор CIL сгенерирует сборку, в которой определено единственное пространство имен .NET Core, содержащее три класса .NET Core.
Во многих случаях директивы CIL сами по себе недостаточно описательны для того, чтобы полностью выразить определение заданного типа .NET Core или члена типа. С учетом этого факта многие директивы CIL могут сопровождаться разнообразными атрибутами CIL, которые уточняют способ обработки директивы. Например, директива
.class
может быть снабжена атрибутом public
(для установления видимости типа), атрибутом extends
(для явного указания базового класса типа) и атрибутом implements
(для перечисления набора интерфейсов, поддерживаемых данным типом).
На заметку! Не путайте атрибут .NET Core (см. главу 17) и атрибут CIL, которые являются двумя совершенно разными понятиями.
После того как сборка, пространство имен и набор типов .NET Core определены в терминах языка CIL с использованием различных директив и связанных атрибутов, остается только предоставить логику реализации для типов. Это работа кодов операций. В традициях других низкоуровневых языков программирования многие коды операций CIL обычно имеют непонятный и совершенно нечитабельный вид. Например, для загрузки в память переменной типа
string
применяется код операции, который вместо дружественного имени наподобие LoadString
имеет имя ldstr
.
Справедливости ради следует отметить, что некоторые коды операций CIL довольно естественно отображаются на свои аналоги в C# (например,
box
, unbox
, throw
и sizeof
). Вы увидите, что коды операций CIL всегда используются внутри области реализации члена и в отличие от директив никогда не записываются с префиксом-точкой.
Как только что объяснялось, для реализации членов отдельно взятого типа применяются коды операций вроде
ldstr
. Однако такие лексемы, как ldstr
, являются мнемоническими эквивалентами CIL фактических двоичных кодов операций CIL. Чтобы выяснить разницу, напишите следующий метод C# в проекте консольного приложения .NET Core по имени FirstSamples
:
int Add(int x, int y)
{
return x + y;
}
В терминах CIL действие сложения двух чисел выражается посредством кода операции
0X58
. В том же духе вычитание двух чисел выражается с помощью кода операции 0X59
, а действие по размещению нового объекта в управляемой куче записывается с использованием кода операции 0X73
. С учетом описанной реальности "код CIL" , обрабатываемый JIT-компилятором, представляет собой не более чем порцию двоичных данных.
К счастью, для каждого двоичного кода операции CIL предусмотрен соответствующий мнемонический эквивалент. Например, вместо кода
0X58
может применяться мнемонический эквивалент add
, вместо 0X59
— sub
, а вместо 0X73
— newob
j. С учетом такой разницы между кодами операций и их мнемоническими эквивалентами декомпиляторы CIL, подобные ildasm.exe
, транслируют двоичные коды операций сборки в соответствующие им мнемонические эквиваленты CIL. Вот как ildasm.exe
представит в CIL предыдущий метод Add(), написанный на языке C# (в зависимости от версии .NET Core вывод может отличаться):
.method assembly hidebysig static int32 Add(int32 x,int32 y) cil managed
{
// Code size 9 (0x9)
.maxstack 2
.locals init ([0] int32 int32 V_0)
IL_0000: /* 00 | */ nop
IL_0001: /* 02 | */ ldarg.0
IL_0002: /* 03 | */ ldarg.1
IL_0003: /* 58 | */ add
IL_0004: /* 0A | */ stloc.0
IL_0005: /* 2B | 00 */ br.s IL_0007
IL_0007: /* 06 | */ ldloc.0
IL_0008: /* 2A | */ ret
} //end of method
Если вы не занимаетесь разработкой исключительно низкоуровневого программного обеспечения .NET Core (вроде специального управляемого компилятора), то иметь дело с числовыми двоичными кодами операций CIL никогда не придется. На практике когда программисты, использующие .NET Core, говорят о "кодах операций CIL", они имеют в виду набор дружественных строковых мнемонических эквивалентов (что и делается в настоящей книге), а не лежащие в основе числовые значения.
В языках .NET Core высокого уровня (таких как С#) предпринимается попытка насколько возможно скрыть из виду низкоуровневые детали CIL. Один из особенно хорошо скрываемых аспектов — тот факт, что CIL является языком программирования, основанным на использовании стека. Вспомните из исследования пространств имен коллекций (см. главу 10), что класс
Stack
может применяться для помещения значения в стек, а также для извлечения самого верхнего значения из стека с целью последующего использования. Разумеется, программисты на языке CIL не работают с объектом типа Stack
для загрузки и выгрузки вычисляемых значений, но применяемый ими образ действий похож на заталкивание и выталкивание.
Формально сущность, используемая для хранения набора вычисляемых значений, называется виртуальным стеком выполнения. Вы увидите, что CIL предоставляет несколько кодов операций, которые служат для помещения значения в стек; такой процесс именуется загрузкой. Кроме того, в CIL определены дополнительные коды операций, которые перемещают самое верхнее значение из стека в память (скажем, в локальную переменную), применяя процесс под названием сохранение.
В мире CIL невозможно напрямую получать доступ к элементам данных, включая локально определенные переменные, входные аргументы методов и данные полей типа. Вместо этого элемент данных должен быть явно загружен в стек и затем извлекаться оттуда для использования в более позднее время (запомните упомянутое требование, поскольку оно содействует пониманию того, почему блок кода CIL может выглядеть несколько избыточным).
На заметку! Вспомните, что код CIL не выполняется напрямую, а компилируется по требованию. Во время компиляции кода CIL многие избыточные аспекты реализации оптимизируются. Более того, если для текущего проекта включена оптимизация кода (на вкладке Build (Сборка) окна свойств проекта в Visual Studio), то компилятор будет также удалять разнообразные избыточные детали CIL.
Чтобы понять, каким образом CIL задействует модель обработки на основе стека, создайте простой метод C# по имени
PrintMessage()
, который не принимает аргументов и возвращает void
. Внутри его реализации будет просто выводиться значение локальной переменной в стандартный выходной поток:
void PrintMessage()
{
string myMessage = "Hello.";
Console.WriteLine(myMessage);
}
Если просмотреть код CIL, который получился в результате трансляции метода
PrintMessage()
компилятором С#, то первым делом обнаружится, что в нем определяется ячейка памяти для локальной переменной с помощью директивы .locals
. Затем локальная строка загружается и сохраняется в этой локальной переменной с применением кодов операций ldstr
(загрузить строку) и stloc.0
(сохранить текущее значение в локальной переменной, находящейся в ячейке 0
).
Далее с помощью кода операции
ldloc.0
(загрузить локальный аргумент по индексу 0
) значение (по индексу 0) загружается в память для использования в вызове метода System.Console.WriteLine()
, представленном кодом операции call
. Наконец, посредством кода операции ret
производится возвращение из функции. Ниже показан (прокомментированный) код CIL для метода PrintMessage()
(ради краткости из листинга были удалены коды операций nop
):
.method assembly hidebysig static void PrintMessage() cil managed
{
.maxstack 1
// Определить локальную переменную типа string (по индексу 0).
.locals init ([0] string V_0)
// Загрузить в стек строку со значением "Hello."
ldstr " Hello."
// Сохранить строковое значение из стека в локальной переменной.
stloc.0
// Загрузить значение по индексу 0.
ldloc.0
// Вызвать метод с текущим значением.
call void [System.Console]System.Console::WriteLine(string)
ret
}
На заметку! Как видите, язык CIL поддерживает синтаксис комментариев в виде двойной косой черты (и вдобавок синтаксис
/*...*/
). Подобно компилятору C# компилятор CIL игнорирует комментарии в коде.
Теперь, когда вы знаете основы директив, атрибутов и кодов операций CIL, давайте приступим к практическому программированию на CIL, начав с рассмотрения темы возвратного проектирования.
В главе 1 было показано, как применять утилиту
ildasm.exe
для просмотра кода CIL, сгенерированного компилятором С#. Тем не менее, вы можете даже не подозревать, что эта утилита позволяет сбрасывать код CIL, содержащийся внутри загруженной в нее сборки, во внешний файл. Полученный подобным образом код CIL можно редактировать и компилировать заново с помощью компилятора CIL (ilasm.exe
).
Выражаясь формально, такой прием называется возвратным проектированием и может быть полезен в избранных обстоятельствах, которые перечислены ниже.
• Вам необходимо модифицировать сборку, исходный код которой больше не доступен.
• Вы работаете с далеким от идеала компилятором языка .NET Core, который генерирует неэффективный (или явно некорректный) код CIL, поэтому нужно изменять кодовую базу.
• Вы конструируете библиотеку взаимодействия с СОМ и хотите учесть ряд атрибутов COM IDL, которые были утрачены во время процесса преобразования (такие как COM-атрибут
[helpstring]
).
Чтобы ознакомиться с процессом возвратного проектирования, создайте новый проект консольного приложения .NET Core на языке C# по имени
RoundTrip
посредством интерфейса командной строки .NET Core (CLI):
dotnet new console -lang c# -n RoundTrip -o .\RoundTrip -f net5.0
Модифицируйте операторы верхнего уровня, как показано ниже:
// Простое консольное приложение С#.
Console.WriteLine("Hello CIL code!");
Console.ReadLine();
Скомпилируйте программу с применением интерфейса CLI:
dotnet build
На заметку! Вспомните из главы 1, что результатом компиляции всех сборок .NET Core (библиотек классов и консольных приложений) будут файлы с расширением
*.dll
, которые выполняются с применением интерфейса .NET Core CLI. Нововведением .NET Core 3+ (и последующих версий) является то, что файл dotnet.exe
копируется в выходной каталог и переименовывается согласно имени сборки. Таким образом, хотя выглядит так, что ваш проект был скомпилирован в RoundTrip.exe
, на самом деле он компилируется в RoundTrip.dll
, а файл dotnet.exe
копируется в RoundTrip.exe
вместе с обязательными аргументами командной строки, необходимыми для запуска Roundtrip.dll
.
Запустите
ildasm.exe
в отношении RoundTrip.dll
, используя следующую команду (на уровне каталога решения):
ildasm /all /METADATA /out=.\RoundTrip\RoundTrip.il
.\RoundTrip\bin\Debug\net5.0\RoundTrip.dll
На заметку! При сбрасывании содержимого сборки в файл утилита
ildasm.exe
также генерирует файл *.res
. Такие ресурсные файлы можно игнорировать (и удалять), поскольку в текущей главе они не применяются. В них содержится низкоуровневая информация, касающаяся безопасности CLR (помимо прочих данных).
Теперь можете просмотреть файл
RoundTrip.il
в любом текстовом редакторе. Вот его содержимое (для удобства оно слегка переформатировано и снабжено комментариями):
// Ссылаемые сборки.
.assembly extern System.Runtime
{
.publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A)
.ver 5:0:0:0
}
.assembly extern System.Console
{
.publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
.ver 5:0:0:0
}
// Наша сборка.
.assembly RoundTrip
{
...
.hash algorithm 0x00008004
.ver 1:0:0:0
}
.module RoundTrip.dll
.imagebase 0x00400000
.file alignment 0x00000200
.stackreserve 0x00100000
.subsystem 0x0003
.corflags 0x00000001
// Определение класса Program.
.class private abstract auto ansi beforefieldinit '$'
extends [System.Runtime]System.Object
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices
.
CompilerGeneratedAttribute::.ctor()
= ( 01 00 00 00 )
.method private hidebysig static void '$'(string[] args) cil managed
{
// Помечает этот метод как точку входа исполняемой сборки.
.entrypoint
.maxstack 8
IL_0000: ldstr "Hello CIL code!"
IL_0005: call void [System.Console]System.Console::WriteLine(string)
IL_000a: nop
IL_000b: call string [System.Console]System.Console::ReadLine()
IL_0010: pop
IL_0011: ret
} // end of method '$'::'$'
} // end of class '$'
Обратите внимание, что файл
*.il
начинается с объявления всех внешних сборок, на которые ссылается текущая скомпилированная сборка. Если бы в вашей библиотеке классов использовались дополнительные типы из других ссылаемых сборок (помимо System.Runtime
и System.Console
), тогда вы обнаружили бы дополнительные директивы .assembly extern
.
Далее следует формальное определение сборки
RoundTrip.dll
, описанное с применением разнообразных директив CIL (.module
, .imagebase
и т.д.).
После документирования внешних ссылаемых сборок и определения текущей сборки находится определение типа
Program
, созданное из операторов верхнего уровня. Обратите внимание, что директива .class
имеет различные атрибуты (многие из которых необязательны) вроде приведенного ниже атрибута extends
, который указывает базовый класс для типа:
.class private abstract auto ansi beforefieldinit '$'
extends [System.Runtime]System.Object
{ ... }
Основной объем кода CIL представляет реализацию стандартного конструктора класса и автоматически сгенерированного метода
Main()
, которые оба определены (частично) посредством директивы .method
. После того, как эти члены были определены с использованием корректных директив и атрибутов, они реализуются с применением разнообразных кодов операций.
Важно понимать, что при взаимодействии с типами .NET Core (такими как System.Console) в CIL всегда необходимо использовать полностью заданное имя типа. Более того, полностью заданное имя типа всегда должно предваряться префиксом в форме дружественного имени сборки, где определен тип (в квадратных скобках). Взгляните на следующую реализацию метода
Main()
в CIL:
.method private hidebysig static void '$'(string[] args) cil managed
{
// Помечает этот метод как точку входа исполняемой сборки.
.entrypoint
.maxstack 8
IL_0000: ldstr "Hello CIL code!"
IL_0005: call void [System.Console]System.Console::WriteLine(string)
IL_000a: nop
IL_000b: call string [System.Console]System.Console::ReadLine()
IL_0010: pop
IL_0011: ret
} // end of method '$'::'$'
Вы определенно заметили, что каждая строка в коде реализации предваряется лексемой в форме
IL_X
XX: (например, IL_0000:
, IL_0001:
и т.д.). Такие лексемы называются метками кода и могут именоваться в любой выбранной вами манере (при условии, что они не дублируются внутри области действия члена). При сбросе содержимого сборки в файл утилита ildasm.exe
автоматически генерирует метки кода, которые следуют соглашению об именовании вида IL_XXXX:
. Однако их можно заменить более описательными маркерами, например:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
.maxstack 8
Nothing_1: nop
Load_String: ldstr "Hello CIL code!"
PrintToConsole: call void [System.Console]System.Console::WriteLine(string)
Nothing_2: nop
WaitFor_KeyPress: call string [System.Console]System.Console::ReadLine()
RemoveValueFromStack: pop
Leave_Function: ret
}
В сущности, большая часть меток кода совершенно не обязательна. Единственный случай, когда метки кода по-настоящему необходимы, связан с написанием кода CIL, в котором используются разнообразные конструкции ветвления или организации циклов, т.к. с помощью меток можно указывать, куда должен быть направлен поток логики. В текущем примере все автоматически сгенерированные метки кода можно удалить безо всяких последствий:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
.maxstack 8
nop
ldstr "Hello CIL code!"
call void [System.Console]System.Console::WriteLine(string)
nop
call string [System.Console]System.Console::ReadLine()
pop
ret
}
Теперь, когда вы имеете представление о том, из чего состоит базовый файл CIL, давайте завершим эксперимент с возвратным проектированием. Цель здесь довольно проста: изменить сообщение, которое выводится в окно консоли. Вы можете делать что-то большее, скажем, добавлять ссылки на сборки или создавать новые классы и методы, но мы ограничимся простым примером.
Чтобы внести изменение, вам понадобится модифицировать текущую реализацию операторов верхнего уровня, созданную в виде метода
$()
. Отыщите этот метод в файле *.il
и измените сообщение на "Hello from altered CIL code!"
.
Фактически код CIL был модифицирован для соответствия следующему определению на языке С#:
static void Main(string[] args)
{
Console.WriteLine("Hello from altered CIL code!");
Console.ReadLine();
}
Предшествующие версии .NET позволяли компилировать файлы
*.il
с применением утилиты ilasm.exe. В .NET Core положение дел изменилось. Для компиляции файлов *.il
вы должны использовать тип проекта Microsoft.NET.Sdk.IL
. На момент написания главы он все еще не был частью стандартного комплекта SDK.
Начните с создания нового каталога на своей машине. Создайте в этом каталоге файл
global.json
, который применяется к текущему каталогу и всем его подкаталогам. Он используется для определения версии комплекта SDK, которая будет задействована при запуске команд .NET Core CLI. Модифицируйте содержимое файла, как показано ниже:
{
"msbuild-sdks": {
"Microsoft.NET.Sdk.IL": "5.0.0-preview.1.20120.5"
}
}
На следующем шаге создается файл проекта. Создайте файл по имени
RoundTrip.ilproj
и приведите его содержимое к следующему виду:
Exe
net5.0
5.0.0-preview.1.20120.5
MicrosoftNetCoreIlasmPackageVersion>
false
Наконец, скопируйте созданный файл
RoundTrip.il
в каталог проекта. Скомпилируйте сборку с применением .NET Core CLI:
dotnet build
Результирующие файлы будут находиться, как обычно, в подкаталоге
bin\debug\net5.0
. На этом этапе новое приложение можно запустить. Разумеется, в окне консоли отобразится обновленное сообщение. Хотя приведенный простой пример не является особенно впечатляющим, он иллюстрирует один из сценариев применения возвратного проектирования на CIL.
Теперь, когда вы знаете, как преобразовывать сборки .NET Core в файлы
*.il
и компилировать файлы *.il
в сборки, можете переходить к более детальному исследованию синтаксиса и семантики языка CIL. В последующих разделах будет поэтапно рассматриваться процесс создания специального пространства имен, содержащего набор типов. Тем не менее, для простоты типы пока не будут иметь логики реализации своих членов. Разобравшись с созданием простых типов, можете переключить внимание на процесс определения "реальных" членов с использованием кодов операций CIL.
Скопируйте файлы
global.json
и NuGet.config
из предыдущего примера в новый каталог проекта. Создайте новый файл проекта по имени CILTypes.ilproj
, содержимое которого показано ниже:
net5.0
5.0.0-preview.1.20120.5
false
Затем создайте в текстовом редакторе новый файл по имени
CILTypes.il
. Первой задачей в проекте CIL является перечисление внешних сборок, которые будут задействованы текущей сборкой. В рассматриваемом примере применяются только типы, находящиеся внутри сборки System.Runtime.dll
. В новом файле понадобится указать директиву .assembly
с уточняющим атрибутом external
. При добавлении ссылки на сборку со строгим именем, подобную System.Runtime.dll
, также должны быть указаны директивы .publickeytoken
и .ver
:
.assembly extern System.Runtime
{
.publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
.ver 5:0:0:0
}
Следующее действие заключается в определении создаваемой сборки с использованием директивы
.assembly
. В простейшем случае сборка может быть определена за счет указания дружественного имени двоичного файла:
// Наша сборка.
.assembly CILTypes{}
Хотя такой код действительно определяет новую сборку .NET Core, обычно внутрь объявления будут помещаться дополнительные директивы. В рассматриваемом примере определение сборки необходимо снабдить номером версии 1.0.0.0 посредством директивы
.ver
(обратите внимание, что числа в номере версии отделяются друг от друга двоеточиями, а не точками, как принято в С#):
// Наша сборка.
.assembly CILTypes
{
.ver 1:0:0:0
}
Из-за того, что сборка
CILTypes
является однофайловой, ее определение завершается с применением следующей директивы .module
, которая обозначает официальное имя двоичного файла .NET Core, т.е. CILTypes.dll
:
// Наша сборка
.assembly CILTypes
{
.ver 1:0:0:0
}
// Модуль нашей однофайловой сборки.
.module CILTypes.dll
Кроме
.assembly
и .module
существуют директивы CIL, которые позволяют дополнительно уточнять общую структуру создаваемого двоичного файла .NET Core. В табл. 19.1 перечислены некоторые наиболее распространенные директивы уровня сборки.
Определив внешний вид и поведение сборки (а также обязательные внешние ссылки), вы можете создать пространство имен .NET Core (
MyNamespace
), используя директиву .namespace
:
// Наша сборка имеет единственное пространство имен.
.namespace MyNamespace {}
Подобно C# определения пространств имен CIL могут быть вложены в другие пространства имен. Хотя здесь нет нужды определять корневое пространство имен, ради интереса посмотрим, как создать корневое пространство имен
MyCompany
:
.namespace MyCompany
{
.namespace MyNamespace {}
}
Как и С#, язык CIL позволяет определить вложенное пространство имен следующим образом:
// Определение вложенного пространства имен.
.namespace MyCompany.MyNamespace {}
Пустые пространства имен не особо интересны, поэтому давайте рассмотрим процесс определения типов классов в CIL. Для определения нового типа класса предназначена директива
.class
. Тем не менее, эта простая директива может быть декорирована многочисленными дополнительными атрибутами, уточняющими природу типа. В целях иллюстрации добавим в наше пространство имен открытый класс под названием MyBaseClass
. Как и в С#, если базовый класс явно не указан, то тип автоматически становится производным от System.Object
:
.namespace MyNamespace
{
// Предполагается базовый класс System.Object.
.class public MyBaseClass {}
}
При построении типа, производного не от класса
System.Object
, применяется атрибут extends
. Для ссылки на тип, определенный внутри той же самой сборки, язык CIL требует использования полностью заданного имени (однако если базовый тип находится внутри той же самой сборки, то префикс в виде дружественного имени сборки можно не указывать). Следовательно, демонстрируемая ниже попытка расширения MyBaseClass
в результате дает ошибку на этапе компиляции:
// Этот код не скомпилируете»!
.namespace MyNamespace
{
.class public MyBaseClass {}
.class public MyDerivedClass
extends MyBaseClass {}
}
Чтобы корректно определить родительский класс для
MyDerivedClass
, потребуется указать полностью заданное имя MyBaseClass
:
// Уже лучше!
.namespace MyNamespace
{
.class public MyBaseClass {}
.class public MyDerivedClass
extends MyNamespace.MyBaseClass {}
}
В дополнение к атрибутам
public
и extends
определение класса CIL может иметь множество добавочных квалификаторов, которые управляют видимостью типа, компоновкой полей и т.д. В табл. 19.2 описаны избранные атрибуты, которые могут применяться в сочетании с директивой .class
.
Несколько странно, но типы интерфейсов в CIL определяются с применением директивы
.class
. Тем не менее, когда директива .class
декорирована атрибутом interface, тип трактуется как интерфейсный тип CTS. После определения интерфейс можно привязывать к типу класса или структуры с использованием атрибута implements
:
.namespace MyNamespace
{
// Определение интерфейса.
.class public interface IMyInterface {}
// Простой базовый класс.
.class public MyBaseClass {}
// Теперь MyDerivedClass реализует IMylnterface
// и расширяет MyBaseClass.
.class public MyDerivedClass
extends MyNamespace.MyBaseClass
implements MyNamespace.IMyInterface {}
}
На заметку! Конструкция
extends
должна предшествовать конструкции implements
. Кроме того, в конструкции implements
может содержаться список интерфейсов с разделителями-запятыми
Вспомните из главы 8, что интерфейсы могут выступать в роли базовых для других типов интерфейсов, позволяя строить иерархии интерфейсов. Однако вопреки возможным ожиданиям применять атрибут
extends
для порождения интерфейса А
от интерфейса В
в CIL нельзя. Атрибут extends
используется только для указания базового класса типа. Когда интерфейс необходимо расширить, снова будет применяться атрибут implements
, например:
// Расширение интерфейсов в CIL.
.class public interface IMyInterface {}
.class public interface IMyOtherInterface
implements MyNamespace.IMyInterface {}
Директива
.class
может использоваться для определения любой структуры CTS, если тип расширяет System.ValueType
. Кроме того, такая директива .class
должна уточняться атрибутом sealed
(учитывая, что структуры никогда не могут выступать в роли базовых для других типов значений). Если попытаться поступить иначе, тогда компилятор ilasm.exe
выдаст сообщение об ошибке.
// Определение структуры всегда является запечатанным.
.class public sealed MyStruct
extends [System.Runtime]System.ValueType{}
Имейте в виду, что в CIL предусмотрен сокращенный синтаксис для определения типа структуры. В случае применения атрибута
value
новый тип автоматически становится производным от [System.Runtime]System.ValueType
. Следовательно, тип MyStruct
можно было бы определить и так:
// Сокращенный синтаксис объявления структуры.
.class public sealed value MyStruct{}
Перечисления .NET Core порождены от класса
System.Enum
, который является System.ValueType
(и потому также должен быть запечатанным). Чтобы определить перечисление в CIL, необходимо просто расширить [System.Runtime]System.Enum
:
// Перечисление.
.class public sealed MyEnum
extends [System.Runtime]System.Enum{}
Подобно структурам перечисления могут быть определены с помощью сокращенного синтаксиса, используя атрибут
enum
:
// Сокращенный синтаксис определения перечисления.
.class public sealed enum MyEnum{}
Вскоре вы увидите, как указывать пары "имя-значение" перечисления.
Обобщенные типы также имеют собственное представление в синтаксисе CIL. Вспомните из главы 10, что обобщенный тип или член может иметь один и более параметров типа. Например, в типе
List
определен один параметр типа, а в Dictionary
— два. В CIL количество параметров типа указывается с применением символа обратной одиночной кавычки ('
), за которым следует число, представляющее количество параметров типа. Как и в С#, действительные значения параметров типа заключаются в угловые скобки.
На заметку! На большинстве клавиатур символ
'
находится на клавише, расположенной над клавишей <ТаЬ> (и слева от клавиши <1>).
Например, предположим, что требуется создать переменную
List
, где Т
— тип System.Int3
2. В C# пришлось бы написать такой код:
void SomeMethod()
{
List myInts = new List();
}
В CIL необходимо поступить следующим образом (этот код может находиться внутри любого метода CIL):
// В C#: List myInts = new List();
newobj instance void class [System.Collections]
System.Collections.Generic.List`1::.ctor()
Обратите внимание, что обобщенный класс определен как
List'1
, поскольку List
имеет единственный параметр типа. А вот как определить тип Dictionary
:
// В C#: Dictionary d = new Dictionary();
newobj instance void class [System.Collections]
System.Collections.Generic.Dictionary`2
::.ctor()
Рассмотрим еще один пример: пусть имеется обобщенный тип, использующий в качестве параметра типа другой обобщенный тип. Код CIL выглядит следующим образом:
// В C#: List> myInts = new List>();
newobj instance void class [mscorlib]
System.Collections.Generic.List`1
[System.Collections]
System.Collections.Generic.List`1>
::.ctor()
Несмотря на то что к определенным ранее типам пока не были добавлены члены или код реализации, вы можете скомпилировать файл
*.il
в DLL-сборку .NET Core (так и нужно поступать ввиду отсутствия метода Main()
). Откройте окно командной строки и введите показанную ниже команду:
dotnet build
Затем можете открыть скомпилированную сборку в
ildasm.exe
, чтобы удостовериться в создании каждого типа. Чтобы понять, каким образом заполнить тип содержимым, сначала необходимо ознакомиться с фундаментальными типами данных CIL.
В табл. 19.3 показано, как базовые классы .NET Core отображаются на соответствующие ключевые слова С#, а ключевые слова C# — на их представления в CIL. Кроме того, для каждого типа CIL приведено сокращенное константное обозначение. Как вы вскоре увидите, на такие константы часто ссылаются многие коды операций CIL.
На заметку! Типы
System.IntPtr
и System.UIntPtr
отображаются на собственные типы int
и unsigned int
в CIL (это полезно знать, т.к. они интенсивно применяются во многих сценариях взаимодействия с СОМ и P/Invoke).
Как вам уже известно, типы .NET Core могут поддерживать разнообразные члены. Перечисления содержат набор пар "имя-значение". Структуры и классы могут иметь конструкторы, поля, методы, свойства, статические члены и т.д. В предшествующих восемнадцати главах книги вы уже видели частичные определения в CIL упомянутых элементов, но давайте еще раз кратко повторим, каким образом различные члены отображаются на примитивы CIL.
Перечисления, структуры и классы могут поддерживать поля данных. Во всех случаях для их определения будет использоваться директива
.field
. Например, добавьте к перечислению MyEnum
следующие три пары "имя-значение" (обратите внимание, что значения указаны в круглых скобках):
.class public sealed enum MyEnum
{
.field public static literal valuetype
MyNamespace.MyEnum A = int32(0)
.field public static literal valuetype
MyNamespace.MyEnum B = int32(1)
.field public static literal valuetype
MyNamespace.MyEnum C = int32(2)
}
Поля, находящиеся внутри области действия производного от
System.Enum
типа .NET Core, уточняются с применением атрибутов static
и literal
. Как не трудно догадаться, эти атрибуты указывают, что данные полей должны быть фиксированными значениями, доступными только из самого типа (например, MyEnum.А
).
На заметку! Значения, присваиваемые полям в перечислении, также могут быть представлены в шестнадцатеричном формате с префиксом
0х
.
Конечно, когда нужно определить элемент поля данных внутри класса или структуры, вы не ограничены только открытыми статическими литеральными данными. Например, класс
MyBaseClass
можно было бы модифицировать для поддержки двух закрытых полей данных уровня экземпляра со стандартными значениями:
.class public MyBaseClass
{
.field private string stringField = "hello!"
.field private int32 intField = int32(42)
}
Как и в С#, поля данных класса будут автоматически инициализироваться подходящими стандартными значениями. Чтобы предоставить пользователю объекта возможность указывать собственные значения во время создания закрытых полей данных, потребуется создать специальные конструкторы.
Спецификация CTS поддерживает создание конструкторов как уровня экземпляра, так и уровня класса (статических). В CIL конструкторы уровня экземпляра представляются с использованием лексемы
.ctor
, тогда как конструкторы уровня класса — посредством лексемы .cctor
(class constructor — конструктор класса). Обе лексемы CIL должны сопровождаться атрибутами rtspecialname
(return type special name — специальное имя возвращаемого типа) и specialname
. Упомянутые атрибуты применяются для обозначения специфической лексемы CIL, которая может трактоваться уникальным образом в любом отдельно взятом языке .NET Core. Например, в языке C# конструкторы не определяют возвращаемый тип, но в CIL возвращаемым значением конструктора на самом деле является void
:
.class public MyBaseClass
{
.field private string stringField
.field private int32 intField
.method public hidebysig specialname rtspecialname
instance void .ctor(string s, int32 i) cil managed
{
// Добавить код реализации...
}
}
Обратите внимание, что директива
.ctor
снабжена атрибутом instance
(поскольку конструктор не статический). Атрибуты cil managed
указывают на то, что внутри данного метода содержится код CIL, а не неуправляемый код, который может использоваться при выполнении запросов Р/Invoke.
Свойства и методы также имеют специфические представления в CIL. В качестве примера модифицируйте класс
MyBaseClass
с целью поддержки открытого свойства по имени TheString
, написав следующий код CIL (обратите внимание на применение атрибута specialname
):
.class public MyBaseClass
{
...
.method public hidebysig specialname
instance string get_TheString() cil managed
{
// Добавить код реализации...
}
.method public hidebysig specialname
instance void set_TheString(string 'value') cil managed
{
// Добавить код реализации...
}
.property instance string TheString()
{
.get instance string
MyNamespace.MyBaseClass::get_TheString()
.set instance void
MyNamespace.MyBaseClass::set_TheString(string)
}
}
В терминах CIL свойство отображается на пару методов, имеющих префиксы
get_
и set_
. В директиве .property
используются связанные директивы .get
и .set
для отображения синтаксиса свойств на подходящие "специально именованные" методы.
На заметку! Обратите внимание, что входной параметр метода
set
в свойстве помещен в одинарные кавычки и представляет имя лексемы, которая должна применяться в правой части операции присваивания внутри области определения метода.
Коротко говоря, параметры в CIL указываются (более или менее) идентично тому, как это делается в С#. Например, каждый параметр определяется путем указания его типа данных, за которым следует имя параметра. Более того, подобно C# язык CIL позволяет определять входные, выходные и передаваемые по ссылке параметры. Вдобавок в CIL допускается определять массив параметров (соответствует ключевому слову
params
в С#), а также необязательные параметры.
Чтобы проиллюстрировать процесс определения параметров в низкоуровневом коде CIL, предположим, что необходимо построить метод, который принимает параметр
int32
(по значению), параметр int32
(по ссылке), параметр [System.Runtime.Extensions]System.Collection.ArrayList
и один выходной параметр (типа int32
). В C# метод выглядел бы примерно так:
public static void MyMethod(int inputInt,
ref int refInt, ArrayList ar, out int outputInt)
{
outputInt = 0; // Просто чтобы удовлетворить компилятор C#...
}
После отображения метода
MyMethod()
на код CIL вы обнаружите, что ссылочные параметры C# помечаются символом амперсанда (&
), который дополняет лежащий в основе тип данных (int32 &
).
Выходные параметры также снабжаются суффиксом
&
, но дополнительно уточняются лексемой [out]
языка CIL. Вдобавок если параметр относится к ссылочному типу ([System.RuntimeExtensions]System.Collections.ArrayList
), то перед типом данных указывается лексема class
(не путайте ее с директивой .class
):
.method public hidebysig static void MyMethod(int32 inputInt,
int32& refInt,
class [System.Runtime.Extensions]System.Collections.ArrayList ar,
[out] int32& outputInt) cil managed
{
...
}
Последний аспект кода CIL, который будет здесь рассматриваться, связан с ролью разнообразных кодов операций. Вспомните, что код операции — это просто лексема CIL, используемая при построении логики реализации для заданного члена.
Все коды операций CIL (которых довольно много) могут быть разделены на три обширные категории:
• коды операций, которые управляют потоком выполнения программы ;
• коды операций, которые вычисляют выражения;
• коды операций, которые получают доступ к значениям в памяти (через параметры, локальные переменные и т.д.).
В табл. 19.4 описаны наиболее полезные коды операций, имеющие прямое отношение к логике реализации членов; они сгруппированы по функциональности.
Коды операций из следующей обширной категории (подмножество которых описано в табл. 19.5) применяются для загрузки (заталкивания) аргументов в виртуальный стек выполнения. Обратите внимание, что все эти ориентированные на загрузку коды операций имеют префикс
Id
(load — загрузить).
В дополнение к набору кодов операций, связанных с загрузкой, CIL предоставляет многочисленные коды операций, которые явно извлекают из стека самое верхнее значение. Как было показано в нескольких начальных примерах, извлечение значения из стека обычно предусматривает его сохранение во временном локальном хранилище с целью дальнейшего использования (наподобие параметра для предстоящего вызова метода). Многие коды операций, извлекающие текущее значение из виртуального стека выполнения, снабжены префиксом
st
(store — сохранить). В табл. 19.6 описаны некоторые распространенные коды операций.
Имейте в виду, что различные коды операций CIL будут неявно извлекать значения из стека во время выполнения своих задач. Например, при вычитании одного числа из другого с применением кода операции
sub
должно быть очевидным то, что перед самим вычислением операция sub
должна извлечь из стека два следующих доступных значения. Результат вычисления снова помещается в стек.
При написании кода реализации методов на низкоуровневом языке CIL необходимо помнить о специальной директиве под названием
.maxstack
. С ее помощью устанавливается максимальное количество переменных, которые могут находиться внутри стека в любой заданный момент времени на протяжении периода выполнения метода. Хорошая новость в том, что директива .maxstack
имеет стандартное значение (8
), которое должно подойти для подавляющего большинства создаваемых методов. Тем не менее, если вы хотите указывать все явно, то можете вручную подсчитать количество локальных переменных в стеке и определить это значение явно:
.method public hidebysig instance void
Speak() cil managed
{
// Внутри области действия этого метода в стеке находится
// в точности одно значение (строковый литерал).
.maxstack 1
ldstr "Hello there..."
call void [mscorlib]System.Console::WriteLine(string)
ret
}
Теперь давайте посмотрим, как объявлять локальные переменные. Предположим, что необходимо построить в CIL метод по имени
MyLocalVariables()
, который не принимает аргументы и возвращает void
, и определить в нем три локальные переменные с типами System.String
, System.Int32
и System.Object
. В C# такой метод выглядел бы следующим образом (вспомните, что локальные переменные не получают стандартные значения и потому перед использованием должны быть инициализированы):
public static void MyLocalVariables()
{
string myStr = "CIL code is fun!";
int myInt = 33;
object myObj = new object();
}
А вот как реализовать метод
MyLocalVariables()
на языке CIL:
.method public hidebysig static void
MyLocalVariables() cil managed
{
.maxstack 8
// Определить три локальные переменные.
.locals init (string myStr, int32 myInt, object myObj)
// Загрузить строку в виртуальный стек выполнения.
ldstr "CIL code is fun!"
// Извлечь текущее значение и сохранить его в локальной переменной [0].
stloc.0
// Загрузить константу типа i4 (сокращение для int32) со значением 33.
ldc.i4.s 33
// Извлечь текущее значение и сохранить его в локальной переменной [1].
stloc.1
// Создать новый объект и поместить его в стек.
newobj instance void [mscorlib]System.Object::.ctor()
// Извлечь текущее значение и сохранить его в локальной переменной [2].
stloc.2
ret
}
Первым шагом при размещении локальных переменных с помощью CIL является применение директивы
.locals
в паре с атрибутом init
. Каждая переменная идентифицируется своим типом данных и необязательным именем. После определения локальных переменных значения загружаются в стек (с использованием различных кодов операций загрузки) и сохраняются в этих локальных переменных (с помощью кодов операций сохранения).
Вы уже видели, каким образом объявляются локальные переменные в CIL с применением директивы
.locals init
; однако осталось еще взглянуть на то, как входные параметры отображаются на локальные переменные. Рассмотрим показанный ниже статический метод С#:
public static int Add(int a, int b)
{
return a + b;
}
Такой с виду невинный метод требует немалого объема кодирования на языке CIL. Во-первых, входные аргументы (
а
и b
) должны быть помещены в виртуальный стек выполнения с использованием кода операции ldarg
(load argument — загрузить аргумент). Во-вторых, с помощью кода операции add
из стека будут извлечены следующие два значения и просуммированы с сохранением результата обратно в стек. В-третьих, сумма будет извлечена из стека и возвращена вызывающему коду посредством кода операции ret
. Дизассемблировав этот метод C# с применением ildasm.exe
, вы обнаружите множество дополнительных лексем, которые были внедрены в процессе компиляции, но основная часть кода CIL довольно проста:
.method public hidebysig static int32 Add(int32 a,
int32 b) cil managed
{
.maxstack 2
ldarg.0 // Загрузить а в стек.
ldarg.1 // Загрузить b в стек.
add // Сложить оба значения.
ret
}
Обратите внимание, что ссылка на два входных аргумента (
а
и b
) в коде CIL производится с использованием их индексных позиций (0
и 1
), т.к. индексация в виртуальном стеке выполнения начинается с нуля.
Во время исследования или написания кода CIL нужно помнить о том, что каждый нестатический метод, принимающий входные аргументы, автоматически получает неявный дополнительный параметр, который представляет собой ссылку на текущий объект (подобно ключевому слову
this
в С#). Скажем, если бы метод Add()
был определен как нестатический:
// Больше не является статическим!
public int Add(int a, int b)
{
return a + b;
}
то входные аргументы
а
и b
загружались бы с применением кодов операций ldarg.1
и ldarg.2
(а не ожидаемых ldarg.0
и ldarg.1
). Причина в том, что ячейка 0 содержит неявную ссылку this
. Взгляните на следующий псевдокод:
// Это ТОЛЬКО псевдокод!
.method public hidebysig static int32 AddTwoIntParams(
MyClass_HiddenThisPointer this, int32 a, int32 b) cil managed
{
ldarg.0 // Load MyClass_HiddenThisPointer onto the stack.
ldarg.1 // Load "a" onto the stack.
ldarg.2 // Load "b" onto the stack.
...
}
Итерационные конструкции в языке программирования C# реализуются посредством ключевых слов
for
, foreach
, while
и do
, каждое из которых имеет специальное представление в CIL. В качестве примера рассмотрим следующий классический цикл
for:
public static void CountToTen()
{
for(int i = 0; i < 10; i++)
{
}
}
Вспомните, что для управления прекращением потока выполнения, когда удовлетворено некоторое условие, используются коды операций
br
(br
, bltn
т.д.). В приведенном примере указано условие, согласно которому выполнение цикла for
должно прекращаться, когда значение локальной переменной i
становится больше или равно 10. С каждым проходом к значению i
добавляется 1, после чего проверяемое условие оценивается заново.
Также вспомните, что в случае применения любого кода операции CIL, предназначенного для ветвления, должна быть определена специфичная метка кода (или две), обозначающая место, куда будет произведен переход при истинном результате оценки условия. С учетом всего сказанного рассмотрим показанный ниже (отредактированный) код CIL, который сгенерирован утилитой
ildasm.exe
(вместе с автоматически созданными метками):
.method public hidebysig static void CountToTen() cil managed
{
.maxstack 2
.locals init (int32 V_0, bool V_1)
IL_0000: ldc.i4.0 // Загрузить это значение в стек.
IL_0001: stloc.0 // Сохранить это значение по индексу 0.
IL_0002: br.s IL_000b // Перейти на метку IL_ 0008.
IL_0003: ldloc.0 // Загрузить значение переменной по индексу 0.
IL_0004: ldc.i4.1 // Загрузить значение 1 в стек.
IL_0005: add // Добавить текущее значение в стеке по индексу 0.
IL_0006: stloc.0
IL_0007: ldloc.0 // Загрузить значение по индексу 0.
IL_0008: ldc.i4.s 10 // Загрузить значение 10 в стек.
IL_0009: clt // Меньше значения в стеке?
IL_000a: stloc.1 // Сохранить результат по индексу 1.
IL_000b: ldloc.1 // Загрузить значение переменной по индексу 1.
IL_000c: brtrue.s IL_0002 // Если истинно, тогда перейти на метку IL 0002.
IL_000d: ret
}
Код CIL начинается с определения локальной переменной типа
int32
и ее загрузки в стек. Затем производятся переходы туда и обратно между метками IL_0008
и IL_0004
, во время каждого из которых значение i
увеличивается на 1 и проверяется на предмет того, что оно все еще меньше 10. Как только условие будет нарушено, осуществляется выход из метода.
Ознакомившись с процессом создания исполняемого файла из файла
*.il
, вероятно у вас возникла мысль о том, что он требует чрезвычайно много работы и затем вопрос, в чем здесь выгода. В подавляющем большинстве случаев вы никогда не будете создавать исполняемый файл .NET Core из файла *.il
. Тем не менее, способность понимать код CIL может принести пользу, когда вам нужно исследовать сборку, для которой отсутствует исходный код.
Существуют также коммерческие инструменты, которые восстанавливают исходный код сборки .NET Core. Если вам доводилось когда-либо пользоваться одним из инструментов подобного рода, то теперь вы знаете, каким образом они работают!
Естественно, процесс построения сложных приложений .NET Core на языке CIL будет довольно-таки неблагодарным трудом. С одной стороны, CIL является чрезвычайно выразительным языком программирования, который позволяет взаимодействовать со всеми программными конструкциями, разрешенными CTS. С другой стороны, написание низкоуровневого кода CIL утомительно, сопряжено с большими затратами времени и подвержено ошибкам. Хотя и правда, что знание — сила, вас может интересовать, насколько важно держать в уме все правила синтаксиса CIL. Ответ: зависит от ситуации. Разумеется, в большинстве случаев при программировании приложений .NET Core просматривать, редактировать или писать код CIL не потребуется. Однако знание основ языка CIL означает готовность перейти к исследованию мира динамических сборок (как противоположности статическим сборкам) и роли пространства имен
System.Reflection.Emit
.
Первым может возникнуть вопрос: чем отличаются статические сборки от динамических? По определению статической сборкой называется двоичная сборка .NET, которая загружается прямо из дискового хранилища, т.е. на момент запроса средой CLR она находится где-то на жестком диске в физическом файле (или в наборе файлов, если сборка многофайловая). Как и можно было предположить, при каждой компиляции исходного кода C# в результате получается статическая сборка.
Что касается динамической сборки, то она создается в памяти на лету с использованием типов из пространства имен
System.Reflection.Emit
, которое делает возможным построение сборки и ее модулей, определений типов и логики реализации на языке CIL во время выполнения. Затем сборку, расположенную в памяти, можно сохранить на диск, получив в результате новую статическую сборку. Ясно, что процесс создания динамических сборок с помощью пространства имен System.Reflection.Emit
требует понимания природы кодов операций CIL.
Несмотря на то что создание динамических сборок является сложной (и редкой) задачей программирования, оно может быть удобным в разнообразных обстоятельствах. Ниже перечислены примеры.
• Вы строите инструмент программирования .NET Core, который должен быть способным генерировать сборки по требованию на основе пользовательского ввода.
• Вы создаете приложение, которое нуждается в генерации посредников для удаленных типов на лету, основываясь на полученных метаданных.
• Вам необходима возможность загрузки статической сборки и динамической вставки в двоичный образ новых типов.
Давайте посмотрим, какие типы доступны в пространстве имен
System.Reflection.Emit
.
Создание динамической сборки требует некоторых знаний кодов операций CIL, но типы из пространства имен
System.Reflection.Emit
максимально возможно скрывают сложность языка CIL. Скажем, вместо указания необходимых директив и атрибутов CIL для определения типа класса можно просто применять класс TypeBuilder
. Аналогично, если нужно определить новый конструктор уровня экземпляра, то не придется задавать лексему specialname
, rtspecialname
или .ctor
; взамен можно использовать класс ConstructorBuilder
. Основные члены пространства имен System.Reflection.Emit
описаны в табл. 19.7.
В целом типы из пространства имен
System.Reflection.Emit
позволяют представлять низкоуровневые лексемы CIL программным образом во время построения динамической сборки. Вы увидите многие из них в рассматриваемом далее примере; тем не менее, тип ILGenerator
заслуживает специального внимания.
Роль типа
ILGenerator
заключается во вставке кодов операций CIL внутрь заданного члена типа. Однако создавать объекты ILGenerator
напрямую невозможно, т.к. этот тип не имеет открытых конструкторов. Взамен объекты ILGenerator
должны получаться путем вызова специфических методов типов, относящихся к построителям (вроде MethodBuilder
и ConstructorBuilder
).
Вот пример:
// Получить объект ILGenerator из объекта ConstructorBuilder
// по имени myCtorBuilder.
ConstructorBuilder myCtorBuilder = /* */;
ILGenerator myCILGen = myCtorBuilder.GetILGenerator();
Имея объект
ILGenerator
, с помощью его методов можно выпускать низкоуровневые коды операций CIL. Некоторые (но не все) методы ILGenerator
кратко описаны в табл. 19.8.
Основным методом класса
ILGenerator
является Emit()
, который работает в сочетании с типом System.Reflection.Emit.Opcodes
. Как упоминалось ранее в главе, данный тип открывает доступ к множеству полей только для чтения, которые отображаются на низкоуровневые коды операций CIL. Полный набор этих членов документирован в онлайновой справочной системе, и далее в главе вы неоднократно встретите примеры их использования.
Чтобы проиллюстрировать процесс определения сборки .NET Core во время выполнения, давайте рассмотрим процесс создания однофайловой динамической сборки по имени
MyAssembly.dll
.
Внутри модуля находится класс
HelloWorld
, который поддерживает стандартный конструктор и специальный конструктор, применяемый для присваивания значения закрытой переменной-члена (theMessage
) типа string
. Вдобавок в классе HelloWorld
имеется открытый метод экземпляра под названием SayНеllo()
, который выводит приветственное сообщение в стандартный поток ввода-вывода, и еще один метод экземпляра по имени GetMsg()
, возвращающий внутреннюю закрытую строку. По существу мы собираемся программно сгенерировать следующий тип класса:
// Этот класс будет создаваться во время выполнения с использованием
// пространства имен System.Reflection.Emit.
public class HelloWorld
{
private string theMessage;
HelloWorld() {}
HelloWorld(string s) {theMessage = s;}
public string GetMsg() {return theMessage;}
public void SayHello()
{
System.Console.WriteLine("Hello from the HelloWorld class!");
}
}
Создайте новый проект консольного приложения по имени
MyAsmBuilder
и добавьте NuGet-пакет System.Reflection.Emit
. Импортируйте в него пространства имен System.Reflection
и System.Reflection.Emit
. Определите в классе Program
статический метод по имени CreateMyAsm()
. Этот единственный метод будет отвечать за решение следующих задач:
• определение характеристик динамической сборки (имя, версия и т.п.);
• реализация типа
HelloClass
;
• возвращение вызывающему методу объекта
AssemblyBuilder
.
Ниже приведен полный код, а затем его анализ:
static AssemblyBuilder CreateMyAsm()
{
// Установить общие характеристики сборки.
AssemblyName assemblyName = new AssemblyName
{
Name = "MyAssembly",
Version = new Version("1.0.0.0")
};
// Создать новую сборку.
var builder = AssemblyBuilder.DefineDynamicAssembly(
assemblyName,AssemblyBuilderAccess.Run);
// Определить имя модуля.
ModuleBuilder module =
builder.DefineDynamicModule("MyAssembly");
// Определить открытый класс по имени HelloWorld.
TypeBuilder helloWorldClass =
module.DefineType("MyAssembly.HelloWorld",
TypeAttributes.Public);
// Определить закрытую переменную-член типа String по имени theMessage.
FieldBuilder msgField = helloWorldClass.DefineField(
"theMessage",
Type.GetType("System.String"),
attributes: FieldAttributes.Private);
// Создать специальный конструктор.
Type[] constructorArgs = new Type[1];
constructorArgs[0] = typeof(string);
ConstructorBuilder constructor =
helloWorldClass.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
constructorArgs);
ILGenerator constructorIl = constructor.GetILGenerator();
constructorIl.Emit(OpCodes.Ldarg_0);
Type objectClass = typeof(object);
ConstructorInfo superConstructor =
objectClass.GetConstructor(new Type[0]);
constructorIl.Emit(OpCodes.Call, superConstructor);
constructorIl.Emit(OpCodes.Ldarg_0);
constructorIl.Emit(OpCodes.Ldarg_1);
constructorIl.Emit(OpCodes.Stfld, msgField);
constructorIl.Emit(OpCodes.Ret);
// Создать стандартный конструктор.
helloWorldClass.DefineDefaultConstructor(
MethodAttributes.Public);
// Создать метод GetMsg().
MethodBuilder getMsgMethod = helloWorldClass.DefineMethod(
"GetMsg",
MethodAttributes.Public,
typeof(string),
null);
ILGenerator methodIl = getMsgMethod.GetILGenerator();
methodIl.Emit(OpCodes.Ldarg_0);
methodIl.Emit(OpCodes.Ldfld, msgField);
methodIl.Emit(OpCodes.Ret);
// Создать метод SayHello().
MethodBuilder sayHiMethod = helloWorldClass.DefineMethod(
"SayHello", MethodAttributes.Public, null, null);
methodIl = sayHiMethod.GetILGenerator();
methodIl.EmitWriteLine("Hello from the HelloWorld class!");
methodIl.Emit(OpCodes.Ret);
// Выпустить класс HelloWorld.
helloWorldClass.CreateType();
return builder;
}
Тело метода начинается с установления минимального набора характеристик сборки с применением типов
AssemblyName
и Version
(определенных в пространстве имен System.Reflection
). Затем производится получение объекта типа AssemblуBuilder
через статический метод AssemblyBuilder.DefineDynamicAssembly()
.
При вызове метода
DefineDynamicAssembly()
должен быть указан режим доступа к определяемой сборке, наиболее распространенные значения которого представлены в табл. 19.9.
Следующая задача связана с определением набора модулей (и имени) для новой сборки. Метод
DefineDynamicModule()
возвращает ссылку на действительный объект типа ModuleBuilder
:
// Создать новую сборку.
var builder = AssemblyBuilder.DefineDynamicAssembly(
assemblyName,AssemblyBuilderAccess.Run);
Тип
ModuleBuilder
играет ключевую роль во время разработки динамических сборок. Как и можно было ожидать, ModuleBuilder
поддерживает несколько членов, которые позволяют определять набор типов, содержащихся внутри модуля (классы, интерфейсы, структуры и т.д.), а также набор встроенных ресурсов (таблицы строк, изображения и т.п.). В табл. 19.10 описаны два метода, относящиеся к созданию. (Обратите внимание, что каждый метод возвращает объект связанного типа, который представляет тип, подлежащий созданию.)
Основным членом класса
ModuleBuilder
является метод DefineType()
. Кроме указания имени типа (в виде простой строки) с помощью перечисления System.Reflection.TypeAttributes
можно описывать формат этого типа. В табл. 19.11 приведены избранные члены перечисления TypeAttributes
.
Теперь, когда вы лучше понимаете роль метода
ModuleBuilder.CreateType()
, давайте посмотрим, как можно выпустить открытый тип класса HelloWorld
и закрытую строковую переменную:
// Определить открытый класс по имени HelloWorld.
TypeBuilder helloWorldClass =
module.DefineType("MyAssembly.HelloWorld",
TypeAttributes.Public);
// Определить закрытую переменную-член типа String по имени theMessage.
FieldBuilder msgField = helloWorldClass.DefineField(
"theMessage",
Type.GetType("System.String"),
attributes: FieldAttributes.Private);
Обратите внимание, что метод
TypeBuilder.DefineField()
предоставляет доступ к объекту типа FieldBuilder
. В классе TypeBuilder
также определены дополнительные методы, которые обеспечивают доступ к другим типам "построителей". Например, метод DefineConstructor()
возвращает объект типа ConstructorBuilder
, метод DefineProperty()
— объект типа PropertyBuilder
и т.д.
Как упоминалось ранее, для определения конструктора текущего типа можно применять метод
TypeBuilder.DefineConstructor()
. Однако когда дело доходит до реализации конструктора HelloClass
, в тело конструктора необходимо вставить низкоуровневый код CIL, который будет отвечать за присваивание входного параметра внутренней закрытой строке. Чтобы получить объект типа ILGenerator
, понадобится вызвать метод GetILGenerator()
из соответствующего типа "построителя" (в данном случае ConstructorBuilder
).
Помещение кода CIL в реализацию членов осуществляется с помощью метода
Emit()
класса ILGenerator
. В самом методе Emit()
часто используется тип класса Opcodes
, который открывает доступ к набору кодов операций CIL через свойства только для чтения. Например, свойство Opcodes.Ret
обозначает возвращение из вызова метода .Opcodes.Stfid
создает присваивание значения переменной-члену, a Opcodes.Call
применяется для вызова заданного метода (конструктора базового класса в данном случае). Итак, логика для реализации конструктора будет выглядеть следующим образом:
// Создать специальный конструктор, принимающий
// единственный аргумент типа string.
Type[] constructorArgs = new Type[1];
constructorArgs[0] = typeof(string);
ConstructorBuilder constructor =
helloWorldClass.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
constructorArgs);
// Выпустить необходимый код CIL для конструктора.
ILGenerator constructorIl = constructor.GetILGenerator();
constructorIl.Emit(OpCodes.Ldarg_0);
Type objectClass = typeof(object);
ConstructorInfo superConstructor =
objectClass.GetConstructor(new Type[0]);
constructorIl.Emit(OpCodes.Call, superConstructor);
// Загрузить в стек указатель this объекта.
constructorIl.Emit(OpCodes.Ldarg_0);
constructorIl.Emit(OpCodes.Ldarg_1);
// Загрузить входной аргумент в виртуальный стек и сохранить его в msgField
constructorIl.Emit(OpCodes.Stfld, msgField);
constructorIl.Emit(OpCodes.Ret);
Как вам теперь уже известно, в результате определения специального конструктора для типа стандартный конструктор молча удаляется. Чтобы снова определить конструктор без аргументов, нужно просто вызвать метод
DefineDefaultConstructor()
типа TypeBuilder
:
// Создать стандартный конструктор.
helloWorldClass.DefineDefaultConstructor(
MethodAttributes.Public);
В заключение давайте исследуем процесс выпуска метода
SayHello()
. Первая задача связана с получением объекта типа MethodBuilder
из переменной helloWorldClass
. После этого можно определить сам метод и получить внутренний объект типа ILGenerator
для вставки необходимых инструкций CIL:
// Создать метод SayHello.
MethodBuilder sayHiMethod = helloWorldClass.DefineMethod(
"SayHello", MethodAttributes.Public, null, null);
methodIl = sayHiMethod.GetILGenerator();
// Вывести строку на консоль.
methodIl.EmitWriteLine("Hello from the HelloWorld class!");
methodIl.Emit(OpCodes.Ret);
Здесь был определен открытый метод (т.к. указано значение
MethodAttributes.Public
), который не имеет параметров и ничего не возвращает (на что указывают значения null
в вызове DefineMethod()
). Также обратите внимание на вызов EmitWriteLine()
. Посредством данного вспомогательного метода класса ILGenerator
можно записать строку в стандартный поток вывода, приложив минимальные усилия.
Теперь, когда у вас есть логика для создания сборки, осталось лишь выполнить сгенерированный код. Логика в вызывающем коде обращается к методу
CreateMyAsm()
, получая ссылку на созданный объект AssemblyBuilder.
Далее вы поупражняетесь с поздним связыванием (см. главу 17) для создания экземпляра класса
HelloWorld
и взаимодействия с его членами. Модифицируйте операторы верхнего уровня, как показано ниже:
using System;
using System.Reflection;
using System.Reflection.Emit;
Console.WriteLine("***** The Amazing Dynamic Assembly Builder App *****");
// Создать объект AssemblyBuilder с использованием вспомогательной функции.
AssemblyBuilder builder = CreateMyAsm();
// Получить тип HelloWorld.
Type hello = builder.GetType("MyAssembly.HelloWorld");
// Создать экземпляр HelloWorld и вызвать корректный конструктор.
Console.Write("-> Enter message to pass HelloWorld class: ");
string msg = Console.ReadLine();
object[] ctorArgs = new object[1];
ctorArgs[0] = msg;
object obj = Activator.CreateInstance(hello, ctorArgs);
// Вызвать метод SayHelloO и отобразить возвращенную строку.
Console.WriteLine("-> Calling SayHello() via late binding.");
MethodInfo mi = hello.GetMethod("SayHello");
mi.Invoke(obj, null);
// Вызвать метод GetMsg().
mi = hello.GetMethod("GetMsg");
Console.WriteLine(mi.Invoke(obj, null));
Фактически только что была построена сборка .NET Core, которая способна создавать и запускать другие сборки .NET Core во время выполнения. На этом исследование языка CIL и роли динамических сборок завершено. Настоящая глава должна была помочь углубить знания системы типов .NET Core, синтаксиса и семантики языка CIL, а также способа обработки кода компилятором C# в процессе его компиляции.
В главе был представлен обзор синтаксиса и семантики языка CIL. В отличие от управляемых языков более высокого уровня, таких как С#, в CIL не просто определяется набор ключевых слов, а предоставляются директивы (используемые для определения конструкции сборки и ее типов), атрибуты (дополнительно уточняющие данные директивы) и коды операций (применяемые для реализации членов типов).
Вы ознакомились с несколькими инструментами, связанными с программированием на CIL, и узнали, как изменять содержимое сборки .NET Core за счет добавления новых инструкций CIL, используя возвратное проектирование. Кроме того, вы изучили способы установления текущей (и ссылаемой) сборки, пространств имен, типов и членов. Был рассмотрен простой пример построения библиотеки кода и исполняемого файла .NET Core с применением CIL и соответствующих инструментов командной строки.
Наконец, вы получили начальное представление о процессе создания динамической сборки. Используя пространство имен
System.Reflection.Emit
, сборку .NET Core можно определять в памяти во время выполнения. Вы видели, что работа с этим пространством имен требует знания семантики кода CIL. Хотя построение динамических сборок не является распространенной задачей при разработке большинства приложений .NET Core, оно может быть полезно в случае создания инструментов поддержки и различных утилит для программирования.