Часть VI Работа с файлами, сериализация объектов и доступ к данным

Глава 20 Файловый ввод-вывод и сериализация объектов

При создании настольных приложений возможность сохранения информации между пользовательскими сеансами является привычным делом. В настоящей главе рассматривается несколько тем, касающихся ввода-вывода, с точки зрения платформы .NET Core. Первая задача связана с исследованием основных типов, определенных в пространстве имен

System.IO
, с помощью которых можно программно модифицировать структуру каталогов и файлов. Вторая задача предусматривает изучение разнообразных способов чтения и записи символьных, двоичных, строковых и находящихся в памяти структур данных.

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

System.IO.Stream
.


На заметку! Чтобы можно было успешно выполнять примеры в главе, IDE-среда Visual Studio должна быть запущена с правами администратора (для этого нужно просто щелкнуть правой кнопкой мыши на значке Visual Studio и выбрать в контекстном меню пункт Запуск от имени администратора). В противном случае при доступе к файловой системе компьютера могут возникать исключения, связанные с безопасностью.

Исследование пространства имен System.IO

В рамках платформы .NET Core пространство имен

System.IO
представляет собой раздел библиотек базовых классов, выделенный службам файлового ввода и вывода, а также ввода и вывода в памяти. Подобно любому пространству имен внутри
System.IO
определен набор классов, интерфейсов, перечислений, структур и делегатов, большинство из которых находятся в сборке
mscorlib.dll
. В дополнение к типам, содержащимся внутри
mscorlib.dll
, в сборке
System.dll
определены дополнительные члены пространства имен
System.IO
.

Многие типы из пространства имен

System.IO
сосредоточены на программной манипуляции физическими каталогами и файлами. Тем не менее, дополнительные типы предоставляют поддержку чтения и записи данных в строковые буферы, а также в области памяти. В табл. 20.1 кратко описаны основные (неабстрактные) классы, которые дают понятие о функциональности, доступной в пространстве имен
System.IO
.



В дополнение к описанным конкретным классам внутри

System.IO
определено несколько перечислений, а также набор абстрактных классов (скажем,
Stream
,
TextReader
и
ТехtWriter
), которые формируют разделяемый полиморфный интерфейс для всех наследников. В главе вы узнаете о многих типах пространства имен
System.IO
.

Классы Directory(Directorylnfо) и File(FileInfo)

Пространство имен

System.IO
предлагает четыре класса, которые позволяют манипулировать индивидуальными файлами, а также взаимодействовать со структурой каталогов машины. Первые два класса,
Directory
и
File
, открывают доступ к операциям создания, удаления, копирования и перемещения через разнообразные статические члены. Тесно связанные с ними классы
FileInfo
и
DirectoryInfo
обеспечивают похожую функциональность в виде методов уровня экземпляра (следовательно, их экземпляры придется создавать с помощью ключевого слова
new
). Классы
Directory
и
File
непосредственно расширяют класс
System.Object
, в то время как
DirectoryInfo
и
FileInfo
являются производными от абстрактного класса
FileSystemInfo
.

Обычно классы

FileInfo
и
DirectoryInfo
считаются лучшим выбором для получения полных сведений о файле или каталоге (например, времени создания или возможности чтения/записи), т.к. их члены возвращают строго типизированные объекты. В отличие от них члены классов
Directory
и
File
, как правило, возвращают простые строковые значения, а не строго типизированные объекты. Тем не менее, это всего лишь рекомендация; во многих случаях одну и ту же работу можно делать с использованием
File/FileInfo
или
Directory/DirectoryInfo
.

Абстрактный базовый класс FileSystemInfo

Классы

DirectoryInfo
и
FileInfo
получают многие линии поведения от абстрактного базового класса
FileSystemInfo
. По большей части члены класса
FileSystemInfo
применяются для выяснения общих характеристик (таких как время создания, разнообразные атрибуты и т.д.) заданного файла или каталога. В табл. 20.2 перечислены некоторые основные свойства, представляющие интерес.



В классе

FileSystemInfо
также определен метод
Delete()
. Он реализуется производными типами для удаления заданного файла или каталога с жесткого диска. Кроме того, перед получением информации об атрибутах можно вызвать метод
Refresh()
, чтобы обеспечить актуальность статистических данных о текущем файле или каталоге.

Работа с типом DirectoryInfо

Первый неабстрактный тип, связанный с вводом-выводом, который мы исследуем здесь —

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



Работа с типом

DirectoryInfo
начинается с указания отдельного пути в параметре конструктора. Если требуется получить доступ к текущему рабочему каталогу (каталогу выполняющегося приложения), то следует применять обозначение в виде точки (
.
). Вот некоторые примеры:


// Привязаться к текущему рабочему каталогу.

DirectoryInfo dir1 = new DirectoryInfo(".");

// Привязаться к C:\Windows, используя дословную строку.

DirectoryInfo dir2 = new DirectoryInfo(@"C:\Windows");


Во втором примере предполагается, что путь, передаваемый конструктору (

С:\Windows
), уже существует на физической машине. Однако при попытке взаимодействия с несуществующим каталогом генерируется исключение
System.IO.DirectoryNotFoundException
. Таким образом, чтобы указать каталог, который пока еще не создан, перед работой с ним понадобится вызвать метод
Create()
:


// Привязаться к несуществующему каталогу, затем создать его.

DirectoryInfo dir3 = new DirectoryInfo(@"C:\MyCode\Testing");

dir3.Create();


Синтаксис пути, используемый в предыдущем примере, ориентирован на Windows. Если вы разрабатываете приложения .NET Core для разных платформ, тогда должны применять конструкции

Path.VolumeSeparatorChar
и
Path.DirectorySeparatorChar
, которые будут выдавать подходящие символы на основе платформы. Модифицируйте предыдущий код, как показано ниже:


DirectoryInfo dir3 = new DirectoryInfo(

  $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}

  MyCode{Path.DirectorySeparatorChar}Testing");


После создания объекта

DirectoryInfo
можно исследовать содержимое лежащего в основе каталога с помощью любого свойства, унаследованного от
FileSystemInfo
. В целях иллюстрации создайте новый проект консольного приложения по имени
DirectorуАрр
и импортируйте в файл кода C# пространства имен
System
и
System.IO
. Измените класс
Program
, добавив представленный далее новый статический метод, который создает объект
DirectoryInfo
, отображенный на
С:\Windows
(при необходимости подкорректируйте путь), и выводит интересные статистические данные:


using System;

using System.IO;


Console.WriteLine("***** Fun with Directory(Info) *****\n");

ShowWindowsDirectoryInfo();

Console.ReadLine();


static void ShowWindowsDirectoryInfo()

{

  // Вывести информацию о каталоге. В случае работы не под

  // управлением Windows подключитесь к другому каталогу.

  DirectoryInfo dir = new DirectoryInfo($@"C{Path.VolumeSeparatorChar} 

  {Path.DirectorySeparatorChar}Windows");

  Console.WriteLine("***** Directory Info *****");

             // Информация о каталоге

  Console.WriteLine("FullName: {0}", dir.FullName);   // Полное имя

  Console.WriteLine("Name: {0}", dir.Name);       // Имя каталога

  Console.WriteLine("Parent: {0}", dir.Parent);      // Родительский каталог

  Console.WriteLine("Creation: {0}", dir.CreationTime); // Время создания

  Console.WriteLine("Attributes: {0}", dir.Attributes); // Атрибуты

  Console.WriteLine("Root: {0}", dir.Root);       // Корневой каталог

  Console.WriteLine("**************************\n");

}


Вывод у вас может отличаться, но быть похожим:


***** Fun with Directory(Info) *****

***** Directory Info *****

FullName: C:\Windows

Name: Windows

Parent:

Creation: 3/19/2019 00:37:22

Attributes: Directory

Root: C:\

**************************

Перечисление файлов с помощью типа DirectoryInfо

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

DirectoryInfо
. Первым делом мы используем метод
GetFiles()
для получения информации обо всех файлах
*.jpg
, расположенных в каталоге
С:\Windows\Web\Wallpaper
.


На заметку! Если вы не работаете на машине с Windows, тогда модифицируйте код, чтобы читать файлы в каком-нибудь каталоге на вашей машине Не забудьте использовать

Path.VolumeSeparatorChar
и
Path.DirectorySeparatorChar
, сделав код межплатформенным.


Метод

GetFiles()
возвращает массив объектов
FileInfo
, каждый из которых открывает доступ к детальной информации о конкретном файле (тип
FileInfo
будет подробно описан далее в главе). Создайте в классе
Program
следующий статический метод:


static void DisplayImageFiles()

{

  DirectoryInfo dir = new

   DirectoryInfo(@"C:\Windows\Web\Wallpaper");

  // Получить все файлы с расширением *.jpg.

  FileInfo[] imageFiles =

   dir.GetFiles("*.jpg", SearchOption.AllDirectories);


  // Сколько файлов найдено?

  Console.WriteLine("Found {0} *.jpg files\n", imageFiles.Length);


  // Вывести информацию о каждом файле.

  foreach (FileInfo f in imageFiles)

  {

   Console.WriteLine("***************************");

   Console.WriteLine("File name: {0}", f.Name      // Имя файла

   Console.WriteLine("File size: {0}", f.Length);    // Размер

   Console.WriteLine("Creation: {0}", f.CreationTime); // Время создания

   Console.WriteLine("Attributes: {0}", f.Attributes); // Атрибуты

   Console.WriteLine("***************************\n");

  }

}


Обратите внимание на указание в вызове

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

Создание подкаталогов с помощью типа DirectoryInfo

Посредством метода

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


static void ModifyAppDirectory()

{

  DirectoryInfo dir = new DirectoryInfo(".");

  // Создать \MyFolder в каталоге запуска приложения.

  dir.CreateSubdirectory("MyFolder");

  // Создать \MyFolder2\Data в каталоге запуска приложения.

  dir.CreateSubdirectory(

   $@"MyFolder2{Path.DirectorySeparatorChar}Data");

}


Получать возвращаемое значение метода

CreateSubdirectory()
не обязательно, но важно знать, что в случае его успешного выполнения возвращается объект
DirectoryInfo
, представляющий вновь созданный элемент. Взгляните на следующую модификацию предыдущего метода:


static void ModifyAppDirectory()

{

  DirectoryInfo dir = new DirectoryInfo(".");

  // Создать \MyFolder в начальном каталоге.

  dir.CreateSubdirectory("MyFolder");

  // Получить возвращенный объект DirectoryInfo.

  DirectoryInfo myDataFolder = dir.CreateSubdirectory(

   $@"MyFolder2{Path.DirectorySeparatorChar}Data");

  // Выводит путь к ..\MyFolder2\Data.

  Console.WriteLine("New Folder is: {0}", myDataFolder);

}


Вызвав метод

ModifyAppDirectory()
в операторах верхнего уровня и запустив программу, в проводнике Windows можно будет увидеть новые подкаталоги.

Работа с типом Directory

Вы видели тип

DirectoryInfo
в действии и теперь готовы к изучению типа
Directory
. По большей части статические члены типа
Directory
воспроизводят функциональность, которая предоставляется членами уровня экземпляра, определенными в
DirectoryInfo
. Тем не менее, вспомните, что члены типа
Directory
обычно возвращают строковые данные, а не строго типизированные объекты
FileInfo/DirectoryInfo
.

Давайте взглянем на функциональность типа Directory; показанный ниже вспомогательный метод отображает имена всех логических устройств на текущем компьютере (с помощью метода

Directory.GetLogicalDrives()
) и применяет статический метод
Directory.Delete()
для удаления созданных ранее подкаталогов
\MyFolder
и
\MyFolder2\Data
:


static void FunWithDirectoryType()

{

  // Вывести список всех логических устройств на текущем компьютере.

  string[] drives = Directory.GetLogicalDrives();

  Console.WriteLine("Here are your drives:");

  foreach (string s in drives)

  {

   Console.WriteLine("--> {0} ", s);

  }


  // Удалить ранее созданные подкаталоги.

  Console.WriteLine("Press Enter to delete directories");

  Console.ReadLine();

  try

  {

   Directory.Delete("MyFolder");

   // Второй параметр указывает, нужно ли удалять внутренние подкаталоги.

   Directory.Delete("MyFolder2", true);

  }

  catch (IOException e)

  {

   Console.WriteLine(e.Message);

  }

}

Работа с типом DriveInfo

Пространство имен

System.IO
содержит класс по имени
DriveInfo
. Подобно
Directory.GetLogicalDrives()
статический метод
DriveInfo.GetDrives()
позволяет выяснить имена устройств на машине. Однако в отличие от
Directory.GetLogicalDrives()
метод
DriveInfo.GetDrives()
предоставляет множество дополнительных деталей (например, тип устройства, доступное свободное пространство и метка тома). Взгляните на следующие операторы верхнего уровня в новом проекте консольного приложения
DriveInfоАрр
:


using System;

using System.IO;

// Получить информацию обо всех устройствах.

DriveInfo[] myDrives = DriveInfo.GetDrives();

// Вывести сведения об устройствах.

foreach(DriveInfo d in myDrives)

{

  Console.WriteLine("Name: {0}", d.Name);    // имя

  Console.WriteLine("Type: {0}", d.DriveType);  // тип

  // Проверить, смонтировано ли устройство.

  if(d.IsReady)

  {

   Console.WriteLine("Free space: {0}", d.TotalFreeSpace);

           // свободное пространство

   Console.WriteLine("Format: {0}", d.DriveFormat);  // формат устройства

   Console.WriteLine("Label: {0}", d.VolumeLabel);  // метка тома

  }

  Console.WriteLine();

}

Console.ReadLine();


Вот возможный вывод:


***** Fun with DriveInfo *****

Name: C:\

Type: Fixed

Free space: 284131119104

Format: NTFS

Label: OS

Name: M:\

Type: Network

Free space: 4711871942656

Format: NTFS

Label: DigitalMedia


К этому моменту вы изучили несколько основных линий поведения классов

Directory
,
DirectoryInfо
и
DriveInfo
. Далее вы ознакомитесь с тем, как создавать, открывать, закрывать и удалять файлы, находящиеся в заданном каталоге.

Работа с типом FileInfo

Как было показано в предыдущем примере

DirectoryApp
, класс
FileInfo
позволяет получать сведения о существующих файлах на жестком диске (такие как время создания, размер и атрибуты) и помогает создавать, копировать, перемещать и удалять файлы. В дополнение к набору функциональности, унаследованной от
FileSystemInfo
, класс
FileInfo
имеет ряд уникальных членов,которые описаны в табл. 20.4.



Обратите внимание, что большинство методов класса

FileInfo
возвращают специфический объект ввода-вывода (например,
FileStream
и
StreamWriter
), который позволяет начать чтение и запись данных в ассоциированный файл во множестве форматов. Вскоре мы исследуем указанные типы, но прежде чем рассмотреть работающий пример, давайте изучим различные способы получения дескриптора файла с использованием класса
FileInfo
.

Метод FileInfo.Create()

Следующий набор примеров находится в проекте консольного приложения по имени

SimpleFileIO
. Один из способов создания дескриптора файла предусматривает применение метода
FileInfo.Create()
:


using System;

using System.IO;

Console.WriteLine("***** Simple IO with the File Type *****\n");

// Измените это на папку на своей машине, к которой вы имеете доступ

// по чтению/записи или запускайте приложение от имени администратора.

var fileName = $@"C{Path.VolumeSeparatorChar}

           {Path.DirectorySeparatorChar}temp

          {Path.
DirectorySeparatorChar}Test.dat";


// Создать новый файл на диске С:.

FileInfo f = new FileInfo(fileName);

FileStream fs = f.Create();


// Использовать объект FileStream...

// Закрыть файловый поток.

fs.Close();


На заметку! В зависимости от имеющихся у вас пользовательских разрешений и конфигурации системы примеры, которые здесь рассматриваются, могут требовать запуска Visual Studio от имени администратора.


Метод

FileInfo.Create()
возвращает тип
FileStream
, который предоставляет синхронную и асинхронную операции записи/чтения лежащего в его основе файла. Имейте в виду, что объект
FileStream
, возвращаемый
FileInfo.Create()
, открывает полный доступ по чтению и записи всем пользователям.

Также обратите внимание, что после окончания работы с текущим объектом

FileStream
необходимо обеспечить закрытие его дескриптора для освобождения внутренних неуправляемых ресурсов потока. Учитывая, что
FileStream
реализует интерфейс
IDisposable
, можно использовать блок
using
и позволить компилятору сгенерировать логику завершения (подробности ищите в главе 8):


var fileName = $@"C{Path.VolumeSeparatorChar}

           {Path.DirectorySeparatorChar}Test.dat";

...

// Поместить файловый поток внутрь оператора using.

FileInfo f1 = new FileInfo(fileName);

using (FileStream fs1 = f1.Create())

{

  // Использовать объект FileStream...

}

f1.Delete();


На заметку! Почти все примеры в этой главе содержат операторы

using
. Можно также использовать новый синтаксис объявлений
using
, но было решено придерживаться операторов
using
, чтобы сосредоточить примеры на исследуемых компонентах
System.IO
.

Метод FileInfо.Open()

С помощью метода

FileInfo.Open()
можно открывать существующие файлы, а также создавать новые файлы с более высокой точностью представления, чем обеспечивает метод
FileInfo.Create()
, поскольку
Open()
обычно принимает несколько параметров для описания общей структуры файла, с которым будет производиться работа. В результате вызова
Open()
возвращается объект
FileStream
. Взгляните на следующий код:


var fileName = $@"C{Path.VolumeSeparatorChar}

          {Path.DirectorySeparatorChar}Test.dat";

...

// Создать новый файл посредством FileInfо.Open().

FileInfo f2 = new FileInfo(fileName);

using(FileStream fs2 = f2.Open(FileMode.OpenOrCreate,

  FileAccess.ReadWrite, FileShare.None))

{

  // Использовать объект FileStream...

}

f2.Delete();


Эта версия перегруженного метода

Open()
требует передачи трех параметров. Первый параметр указывает общий тип запроса ввода-вывода (например, создать новый файл, открыть существующий файл или дописать в файл), который представлен в виде перечисления
FileMode
(описание его членов приведено в табл. 20.5):



public enum FileMode

{

  CreateNew,

  Create,

  Open,

  OpenOrCreate,

  Truncate,

  Append

}


Второй параметр метода

Open()
— значение перечисления
FileAccess
— служит для определения поведения чтения/записи лежащего в основе потока:


public enum FileAccess

{

  Read,

  Write,

  ReadWrite

}


Наконец, третий параметр метода

Open()
— значение перечисления
FileShare
— указывает, каким образом файл может совместно использоваться другими файловыми дескрипторами:


public enum FileShare

{

  None,

  Read,

  Write,

  ReadWrite,

  Delete,

  Inheritable

}

Методы FileInfо.OpenRead() и FileInfо.OpenWrite()

Метод

FileInfо.Open()
позволяет получить дескриптор файла в гибкой манере, но класс
FileInfо
также предлагает методы
OpenRead()
и
OpenWrite()
. Как и можно было ожидать, указанные методы возвращают подходящим образом сконфигурированный только для чтения или только для записи объект
FileStream
без необходимости в предоставлении значений разных перечислений. Подобно
FileInfо.Create()
и
FileInfо.Open()
методы
OpenRead()
и
OpenWrite()
возвращают объект
FileStream
.

Обратите внимание, что метод

OpenRead()
требует, чтобы файл существовал. Следующий код создает файл и затем закрывает объект
FileStream
, так что он может использоваться методом
OpenRead()
:


f3.Create().Close();


Вот полный пример:


var fileName = $@"C{Path.VolumeSeparatorChar}

          {Path.DirectorySeparatorChar}Test.dat";

...

// Получить объект FileStream с правами только для чтения.

FileInfo f3 = new FileInfo(fileName);

// Перед использованием OpenRead() файл должен существовать.

f3.Create().Close();

using(FileStream readOnlyStream = f3.OpenRead())

{

  // Использовать объект FileStream...

}

f3.Delete();


// Теперь получить объект FileStream с правами только для записи.

FileInfo f4 = new FileInfo(fileName);

using(FileStream writeOnlyStream = f4.OpenWrite())

{

  // Использовать объект FileStream...

}

f4.Delete();

Метод FileInfо.OpenText()

Еще одним членом типа

FileInfo
, связанным с открытием файлов, является
OpenText()
. В отличие от
Create()
,
Open()
,
OpenRead()
и
OpenWrite()
метод
OpenText()
возвращает экземпляр типа
StreamReader
, а не
FileStream
. Исходя из того, что на диске С: имеется файл по имени
boot.ini
, вот как получить доступ к его содержимому:


var fileName = $@"C{Path.VolumeSeparatorChar}

          {Path.DirectorySeparatorChar}Test.dat";

...

// Получить объект StreamReader.

// Если вы работаете не на машине с Windows,

// тогда измените имя файла надлежащим образом.

FileInfo f5 = new FileInfo(fileName);

// Перед использованием OpenText() файл должен существовать.

f5.Create().Close();

using(StreamReader sreader = f5.OpenText())

{

  // Использовать объект StreamReader...

}

f5.Delete();


Вскоре вы увидите, что тип

StreamReader
предоставляет способ чтения символьных данных из лежащего в основе файла.

Методы FileInfo.CreateText() и FileInfo.AppendText()

Последними двумя методами, представляющими интерес в данный момент, являются

CreateText()
и
AppendText()
. Оба они возвращают объект
StreamWriter
:


var fileName = $@"C{Path.VolumeSeparatorChar}

   {Path.DirectorySeparatorChar}Test.dat";

...

FileInfo f6 = new FileInfo(fileName);

using(StreamWriter swriter = f6.CreateText())

{

  // Использовать объект StreamWriter...

}

f6.Delete();

FileInfo f7 = new FileInfo(fileName);

using(StreamWriter swriterAppend = f7.AppendText())

{

  // Использовать объект StreamWriter...

}

f7.Delete();


Как и можно было ожидать, тип

StreamWriter
предлагает способ записи данных в связанный с ним файл.

Работа с типом File

В типе

File
определено несколько статических методов для предоставления функциональности, почти идентичной той, которая доступна в типе
FileInfo
. Подобно
FileInfо
тип
File
поддерживает методы
AppendText()
,
Create()
,
CreateText()
,
Open()
,
OpenRead()
,
OpenWrite()
и
OpenText()
. Во многих случаях типы
File
и
FileInfo
могут применяться взаимозаменяемо. Обратите внимание, что методы
OpenText()
и
OpenRead()
требуют существования файла. Чтобы взглянуть на тип
File
в действии, упростите приведенные ранее примеры использования типа
FileStream
, применив в каждом из них тип
File
:


var fileName = $@"C{Path.VolumeSeparatorChar}

          {Path.DirectorySeparatorChar}Test.dat";

...

// Использование File вместо FileInfo.

using (FileStream fs8 = File.Create(fileName))

{

  // Использовать объект FileStream...

}

File.Delete(fileName);

// Создать новый файл через File.Open().

using(FileStream fs9 =  File.Open(fileName,

  FileMode.OpenOrCreate, FileAccess.ReadWrite,

  FileShare.None))

{

  // Использовать объект FileStream...

}

// Получить объект FileStream с правами только для чтения.

using(FileStream readOnlyStream = File.OpenRead(fileName))

{}

File.Delete(fileName);

// Получить объект FileStream с правами только для записи.

using(FileStream writeOnlyStream = File.OpenWrite(fileName))

{}

// Получить объект StreamReader.

using(StreamReader sreader = File.OpenText(fileName))

{}

File.Delete(fileName);

// Получить несколько объектов StreamWriter.

using(StreamWriter swriter = File.CreateText(fileName))

{}

File.Delete(fileName);


using(StreamWriter swriterAppend =

  File.AppendText(fileName))

{}

File.Delete(fileName);

Дополнительные члены типа File

Тип

File
также поддерживает несколько членов, описанных в табл. 20.6, которые могут значительно упростить процессы чтения и записи текстовых данных.



Приведенные в табл. 20.6 методы типа

File
можно использовать для реализации чтения и записи пакетов данных посредством всего нескольких строк кода. Еще лучше то, что эти методы автоматически закрывают лежащий в основе файловый дескриптор. Например, следующий проект консольного приложения (по имени
SimpleFileIO
) сохраняет строковые данные в новом файле на диске С: (и читает их в память) с минимальными усилиями (здесь предполагается, что было импортировано пространство имен
System.IO
):


Console.WriteLine("***** Simple I/O with the File Type *****\n");

string[] myTasks = {

  "Fix bathroom sink", "Call Dave",

  "Call Mom and Dad", "Play Xbox One"};


// Записать все данные в файл на диске С:.

File.WriteAllLines(@"tasks.txt", myTasks);


// Прочитать все данные и вывести на консоль.

foreach (string task in File.ReadAllLines(@"tasks.txt"))

{

  Console.WriteLine("TODO: {0}", task);

}

Console.ReadLine();

File.Delete("tasks.txt");


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

File
позволит сэкономить на объеме кодирования. Тем не менее, преимущество предварительного создания объекта
FileInfo
заключается в возможности сбора сведений о файле с применением членов абстрактного базового класса
FileSystemInfo
.

Абстрактный класс Stream

Вы уже видели много способов получения объектов

FileStream
,
StreamReader
и
StreamWriter
, но с использованием упомянутых типов нужно еще читать данные или записывать их в файл. Чтобы понять, как это делается, необходимо освоить концепцию потока. В мире манипуляций вводом-выводом поток (stream) представляет порцию данных, протекающую между источником и приемником. Потоки предоставляют общий способ взаимодействия с последовательностью байтов независимо от того, устройство какого рода (файл, сетевое подключение либо принтер) хранит или отображает байты.

Абстрактный класс

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


На заметку! Концепция потока не ограничена файловым вводом-выводом. Естественно, библиотеки .NET Core предлагают потоковый доступ к сетям, областям памяти и прочим абстракциям, связанным с потоками.


Потомки класса

Stream
представляют данные в виде низкоуровневых потоков байтов; следовательно, работа непосредственно с низкоуровневыми потоками может оказаться не особенно понятной. Некоторые типы, производные от
Stream
, поддерживают позиционирование, которое означает процесс получения и корректировки текущей позиции в потоке. В табл. 20.7 приведено описание основных членов класса
Stream
, что помогает понять его функциональность.


Работа с типом FileStream

Класс

FileStream
предоставляет реализацию абстрактных членов
Stream
в манере, подходящей для потоковой работы с файлами. Это элементарный поток; он может записывать или читать только одиночный байт или массив байтов. Однако напрямую взаимодействовать с членами типа
FileStream
вам придется нечасто. Взамен вы, скорее всего, будете применять разнообразные оболочки потоков, которые облегчают работу с текстовыми данными или типами .NET Core. Тем не менее, полезно поэкспериментировать с возможностями синхронного чтения/записи типа
FileStream
.

Пусть имеется новый проект консольного приложения под названием

FileStreamApp
(и в файле кода C# импортировано пространство имен
System.IO
и
System.Text
). Целью будет запись простого текстового сообщения в новый файл по имени
myMessage.dat
. Однако с учетом того, что
FileStream
может оперировать только с низкоуровневыми байтами, объект типа
System.String
придется закодировать в соответствующий байтовый массив. К счастью, в пространстве имен
System.Text
определен тип
Encoding
, предоставляющий члены, которые кодируют и декодируют строки в массивы байтов.

После кодирования байтовый массив сохраняется в файле с помощью метода

FileStream.Write()
. Чтобы прочитать байты обратно в память, понадобится сбросить внутреннюю позицию потока (посредством свойства
Position
) и вызвать метод
ReadByte()
. Наконец, на консоль выводится содержимое низкоуровневого байтового массива и декодированная строка. Ниже приведен полный код:


using System;

using System.IO;

using System.Text;


// He забудьте импортировать пространства имен System.Text и System.IO.

Console.WriteLine("***** Fun with FileStreams *****\n");


// Получить объект FileStream.

using(FileStream fStream = File.Open("myMessage.dat",

  FileMode.Create))

{

  // Закодировать строку в виде массива байтов.

  string msg = "Hello!";

  byte[] msgAsByteArray = Encoding.Default.GetBytes(msg);


  // Записать byte[] в файл.

  fStream.Write(msgAsByteArray, 0, msgAsByteArray.Length);


  // Сбросить внутреннюю позицию потока.

  fStream.Position = 0;


  // Прочитать byte[] из файла и вывести на консоль.

  Console.Write("Your message as an array of bytes: ");

  byte[] bytesFromFile = new byte[msgAsByteArray.Length];

  for (int i = 0; i < msgAsByteArray.Length; i++)

  {

   bytesFromFile[i] = (byte)fStream.ReadByte();

   Console.Write(bytesFromFile[i]);

  }

  // Вывести декодированное сообщение.

  Console.Write("\nDecoded Message: ");

  Console.WriteLine(Encoding.Default.GetString(bytesFromFile));

  Console.ReadLine();

}

File.Delete("myMessage.dat");


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

FileStream
: необходимость оперирования низкоуровневыми байтами. Другие производные от
Stream
типы работают в похожей манере. Например, чтобы записать последовательность байтов в область памяти, понадобится создать объект
MemoryStream
.

Как упоминалось ранее, в пространстве имен

System.IO
доступно несколько типов для средств чтения и записи, которые инкапсулируют детали работы с типами, производными от
Stream
.

Работа с типами StreamWriter и StreamReader

Классы

StreamWriter
и
StreamReader
удобны всякий раз, когда нужно читать или записывать символьные данные (например, строки). Оба типа по умолчанию работают с символами Unicode; тем не менее, это можно изменить за счет предоставления должным образом сконфигурированной ссылки на объект
System.Text.Encoding
. Чтобы не усложнять пример, предположим, что стандартная кодировка Unicode вполне устраивает.

Класс

StreamReader
является производным от абстрактного класса по имени
TextReader
, как и связанный с ним тип
StringReader
(обсуждается далее в главе). Базовый класс
TextReader
предоставляет каждому из своих наследников ограниченный набор функциональных средств, в частности возможность читать и "заглядывать" в символьный поток.

Класс

StreamWriter
(а также
StringWriter
, который будет рассматриваться позже) порожден от абстрактного базового класса по имени
TextWriter
, в котором определены члены, позволяющие производным типам записывать текстовые данные в текущий символьный поток.

Чтобы содействовать пониманию основных возможностей записи в классах

StreamWriter
и
StringWriter
, в табл. 20.8 перечислены основные члены абстрактного базового класса
TextWriter
.



На заметку! Вероятно, последние два члена класса

TextWriter
покажутся знакомыми. Вспомните, что тип
System.Console
имеет члены
Write()
и
WriteLine()
, которые выталкивают текстовые данные на стандартное устройство вывода. В действительности свойство
Console.In
является оболочкой для объекта
TextWriter
, a
Console.Out
— для
TextWriter
.


Производный класс

StreamWriter
предоставляет подходящую реализацию методов
Write()
,
Close()
и
Flush()
, а также определяет дополнительное свойство
AutoFlush
. Установка этого свойства в
true
заставляет
StreamWriter
выталкивать данные при каждой операции записи. Имейте в виду, что за счет установки
AutoFlush
в
false
можно достичь более высокой производительности, но по завершении работы с объектом
StreamWriter
должен быть вызван метод
Close()
.

Запись в текстовый файл

Чтобы увидеть класс

StreamWriter
в действии, создайте новый проект консольного приложения по имени
StreamWriterReaderApp
и импортируйте пространства имен
System.IO
и
System.Text
. В показанном ниже коде с помощью метода
File.CreateText()
создается новый файл
reminders.txt
внутри текущего каталога выполнения. С применением полученного объекта
StreamWriter
в новый файл будут добавляться текстовые данные.


using System;

using System.IO;

using System.Text;

Console.WriteLine("***** Fun with StreamWriter / StreamReader *****\n");


// Получить объект StreamWriter и записать строковые данные.

using(StreamWriter writer = File.CreateText("reminders.txt"))

{

  writer.WriteLine("Don't forget Mother's Day this year...");

  writer.WriteLine("Don't forget Father's Day this year...");

  writer.WriteLine("Don't forget these numbers:");

  for(int i = 0; i < 10; i++)

  {

   writer.Write(i + " ");

  }


  // Вставить новую строку.

  writer.Write(writer.NewLine);

}

Console.WriteLine("Created file and wrote some thoughts...");

Console.ReadLine();

//File.Delete("reminders.txt");


После выполнения программы можете просмотреть содержимое созданного файла, который будет находиться в корневом каталоге проекта (Visual Studio Code) или в подкаталоге

bin\Debug\net5.0
(Visual Studio). Причина в том, что при вызове
CreateText()
вы не указали абсолютный путь, а стандартным местоположением является текущий каталог выполнения сборки.

Чтение из текстового файла

Далее вы научитесь программно читать данные из файла, используя соответствующий тип

StreamReader
. Вспомните, что
StreamReader
является производным от абстрактного класса
TextReader
, который предлагает функциональность, описанную в табл. 20.9.



Расширьте текущий пример приложения с целью применения класса

StreamReader
, чтобы в нем можно было читать текстовые данные из файла
reminders.txt
:


Console.WriteLine("***** Fun with StreamWriter/StreamReader *****\n");

...

// Прочитать данные из файла.

Console.WriteLine("Here are your thoughts:\n");

using(StreamReader sr = File.OpenText("reminders.txt"))

{

  string input = null;

  while ((input = sr.ReadLine()) != null)

  {

   Console.WriteLine (input);

  }

}

Console.ReadLine();


После запуска программы в окне консоли отобразятся символьные данные из файла

reminders.txt
.

Прямое создание объектов типа StreamWriter/StreamReader

Один из запутывающих аспектов работы с типами пространства имен

System.IO
связан с тем, что идентичных результатов часто можно добиться с использованием разных подходов. Например, ранее вы уже видели, что метод
CreateText()
позволяет получить объект
StreamWriter
с типом
File
или
FileInfo
. Вообще говоря, есть еще один способ работы с объектами
StreamWriter
и
StreamReader
: создание их напрямую. Скажем, текущее приложение можно было бы переделать следующим образом:


Console.WriteLine("***** Fun with StreamWriter/StreamReader *****\n");

// Получить объект StreamWriter и записать строковые данные.

using(StreamWriter writer = new StreamWriter("reminders.txt"))

{

  ...

}

// Прочитать данные из файла.

using(StreamReader sr = new StreamReader("reminders.txt"))

{

  ...

}


Несмотря на то что существование такого количества на первый взгляд одинаковых подходов к файловому вводу-выводу может сбивать с толку, имейте в виду,что конечным результатом является высокая гибкость. Теперь, когда вам известно, как перемещать символьные данные в файл и из файла с применением классов

StreamWriter
и
StreamReader
, давайте займемся исследованием роли классов
StringWriter
и
StringReader
.

Работа с типами StringWriter и StringReader

Классы

StringWriter
и
StringReader
можно использовать для трактовки текстовой информации как потока символов в памяти. Это определенно может быть полезно, когда нужно добавить символьную информацию к лежащему в основе буферу. Для иллюстрации в следующем проекте консольного приложения (
StringReaderWriterApp
) блок строковых данных записывается в объект
StringWriter
вместо файла на локальном жестком диске (не забудьте импортировать пространства имен
System.IO
и
System.Text
):


using System;

using System.IO;

using System.Text;


Console.WriteLine("***** Fun with StringWriter/StringReader *****\n");


// Создать объект StringWriter и записать символьные данные в память.

using(StringWriter strWriter = new StringWriter())

{

  strWriter.WriteLine("Don't forget Mother's Day this year...");

  // Получить копию содержимого (хранящегося в строке) и вывести на консоль.

  Console.WriteLine("Contents of StringWriter:\n{0}", strWriter);

}

Console.ReadLine();


Классы

StringWriter
и
StreamWriter
порождены от одного и того же базового класса (
TextWriter
), поэтому логика записи похожа. Тем не менее, с учетом природы
StringWriter
вы должны также знать, что данный класс позволяет применять метод
GetStringBuilder()
для извлечения объекта
System.Text.StringBuilder
:


using (StringWriter strWriter = new StringWriter())

{

  strWriter.WriteLine("Don't forget Mother's Day this year...");

  Console.WriteLine("Contents of StringWriter:\n{0}", strWriter);


  // Получить внутренний объект StringBuilder.

  StringBuilder sb = strWriter.GetStringBuilder();

  sb.Insert(0, "Hey!! ");

  Console.WriteLine("-> {0}", sb.ToString());

  sb.Remove(0, "Hey!! ".Length);

  Console.WriteLine("-> {0}", sb.ToString());

}


Когда необходимо прочитать из потока строковые данные, можно использовать соответствующий тип

StringReader
, который (вполне ожидаемо) функционирует идентично
StreamReader
. Фактически класс
StringReader
лишь переопределяет унаследованные члены, чтобы выполнять чтение из блока символьных данных, а не из файла:


using (StringWriter strWriter = new StringWriter())

{

  strWriter.WriteLine("Don't forget Mother's Day this year...");

  Console.WriteLine("Contents of StringWriter:\n{0}", strWriter);


  // Читать данные из объекта StringWriter.

  using (StringReader strReader = new StringReader(strWriter.ToString()))

  {

   string input = null;

   while ((input = strReader.ReadLine()) != null)

   {

    Console.WriteLine(input);

   }

  }

}

Работа с типами BinaryWriter и BinaryReader

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

BinaryWriter
и
BinaryReader
; они оба унаследованы прямо от
System.Object
. Типы
BinaryWriter
и
BinaryReader
позволяют читать и записывать в поток дискретные типы данных в компактном двоичном формате. В классе
BinaryWriter
определен многократно перегруженный метод
Write()
, предназначенный для помещения некоторого типа данных в поток. Помимо
Write()
класс
BinaryWriter
предоставляет дополнительные члены, которые позволяют получать или устанавливать объекты производных от Stream типов; кроме того, класс
BinaryWriter
также предлагает поддержку произвольного доступа к данным (табл. 20.10).



Класс

BinaryReader
дополняет функциональность класса
BinaryWriter
членами, описанными в табл. 20.11.



В показанном далее примере (проект консольного приложения по имени

BinaryWriterReader
с оператором
using
для
System.IO
) в файл
*.dat
записываются данные нескольких типов:


using System;

using System.IO;

Console.WriteLine("***** Fun with Binary Writers / Readers *****\n");

// Открыть средство двоичной записи в файл.

FileInfo f = new FileInfo("BinFile.dat");

using(BinaryWriter bw = new BinaryWriter(f.OpenWrite()))

{

  // Вывести на консоль тип BaseStream

  // (System.IO. Filestream в этом случае).

  Console.WriteLine("Base stream is: {0}", bw.BaseStream);


  // Создать некоторые данные для сохранения в файле.

  double aDouble = 1234.67;

  int anInt = 34567;

  string aString = "A, B, C";


  // Записать данные.

  bw.Write(aDouble);

  bw.Write(anInt);

  bw.Write(aString);

}

Console.WriteLine("Done!");

Console.ReadLine();


Обратите внимание, что объект

FileStream
, возвращенный методом
FileInfo.OpenWrite()
, передается конструктору типа
BinaryWriter
. Применение такого приема облегчает организацию потока по уровням перед записью данных. Конструктор класса
BinaryWriter
принимает любой тип, производный от
Stream
(например,
FileStream
,
MemoryStream
или
BufferedStream
). Таким образом, запись двоичных данных в память сводится просто к использованию допустимого объекта
MemoryStream
.

Для чтения данных из файла

BinFile.dat
в классе
BinaryReader
предлагается несколько способов. Ниже для извлечения каждой порции данных из файлового потока вызываются разнообразные члены, связанные с чтением:


...

FileInfo f = new FileInfo("BinFile.dat");

...

// Читать двоичные данные из потока.

using(BinaryReader br = new BinaryReader(f.OpenRead()))

{

  Console.WriteLine(br.ReadDouble());

  Console.WriteLine(br.ReadInt32());

  Console.WriteLine(br.ReadString());

}

Console.ReadLine();

Программное слежение за файлами

Теперь, когда вы знаете, как применять различные средства чтения и записи, давайте займемся исследованием роли класса

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


IO.NotifyFilters:

public enum NotifyFilters

{

  Attributes, CreationTime,

  DirectoryName, FileName,

  LastAccess, LastWrite,

  Security, Size

}


Чтобы начать работу с типом

FileSystemWatcher
, в свойстве
Path
понадобится указать имя (и местоположение) каталога, содержащего файлы, которые нужно отслеживать, а в свойстве
Filter
— расширения отслеживаемых файлов.

В настоящий момент можно выбрать обработку событий

Changed
,
Created
и
Deleted
, которые функционируют в сочетании с делегатом
FileSystemEventHandler
. Этот делегат может вызывать любой метод, соответствующий следующей сигнатуре:


// Делегат FileSystemEventHandler должен указывать

// на методы, соответствующие следующей сигнатуре.

void MyNotificationHandler(object source, FileSystemEventArgs e)


Событие

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


// Делегат RenamedEventHandler должен указывать

// на методы, соответствующие следующей сигнатуре.

void MyRenamedHandler(object source, RenamedEventArgs e)


В то время как для обработки каждого события можно применять традиционный синтаксис делегатов/событий, вы определенно будете использовать синтаксис лямбда-выражений.

Давайте взглянем на процесс слежения за файлом. Показанный ниже проект консольного приложения(

MyDirectoryWatcher
с оператором
using
для
System.IO
) наблюдает за файлами
*.txt
в каталоге
bin\debug\net5.0
и выводит на консоль сообщения, когда происходит их создание, удаление, модификация и переименование:


using System;

using System.IO;

Console.WriteLine("***** The Amazing File Watcher App *****\n");

// Установить путь к каталогу, за которым нужно наблюдать.

FileSystemWatcher watcher = new FileSystemWatcher();

try

{

  watcher.Path = @".";

}

catch(ArgumentException ex)

{

 Console.WriteLine(ex.Message);

  return;

}

// Указать цели наблюдения.

watcher.NotifyFilter = NotifyFilters.LastAccess

  | NotifyFilters.LastWrite

  | NotifyFilters.FileName

  | NotifyFilters.DirectoryName;

// Следить только за текстовыми файлами.

watcher.Filter = "*.txt";

// Добавить обработчики событий.

// Указать, что будет происходить при изменении,

// создании или удалении файла.

watcher.Changed += (s, e) =>

  Console.WriteLine($"File: {e.FullPath} {e.ChangeType}!");

watcher.Created += (s, e) =>

  Console.WriteLine($"File: {e.FullPath} {e.ChangeType}!");

watcher.Deleted += (s, e) =>

  Console.WriteLine($"File: {e.FullPath} {e.ChangeType}!");

// Указать, что будет происходить при переименовании файла.

watcher.Renamed += (s, e) =>

  Console.WriteLine($"File: {e.OldFullPath} renamed to {e.FullPath}");

// Начать наблюдение за каталогом.

watcher.EnableRaisingEvents = true;

// Ожидать от пользователя команды завершения программы.

Console.WriteLine(@"Press 'q' to quit app.");

// Сгенерировать несколько событий.

using (var sw = File.CreateText("Test.txt"))

{

  sw.Write("This is some text");

}

File.Move("Test.txt","Test2.txt");

File.Delete("Test2.txt");

while(Console.Read()!='q');


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

bin\debug\net5.0
и поработать с файлами (имеющими расширение
*.txt
), что приведет к инициированию дополнительных событий.


***** The Amazing File Watcher App *****

Press 'q' to quit app.

File: .\Test.txt Created!

File: .\Test.txt Changed!

File: .\Test.txt renamed to .\Test2.txt

File: .\Test2.txt Deleted!


На этом знакомство с фундаментальными операциями ввода-вывода, предлагаемыми платформой .NET Core, завершено. Вы наверняка будете применять все продемонстрированные приемы во многих приложениях. Вдобавок вы обнаружите, что службы сериализации объектов способны значительно упростить задачу сохранения больших объемов данных.

Понятие сериализации объектов

Термин сериализация описывает процесс сохранения (и возможно передачи) состояния объекта в потоке (например, файловом потоке или потоке в памяти). Сохраненная последовательность данных содержит всю информацию, необходимую для воссоздания (или десериализации) открытого состояния объекта с целью последующего использования. Применение такой технологии делает тривиальным сохранение крупных объемов данных (в разнообразных форматах). Во многих случаях сохранение данных приложения с использованием служб сериализации дает в результате меньше кода, чем с применением средств чтения/записи из пространства имен

System.IO
.

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

UserPrefs
и инкапсулировать в нем около двадцати полей данных. В случае использования типа
System.IO.BinaryWriter
пришлось бы вручную сохранять каждое поле объекта
UserPrefs
. Подобным же образом при загрузке данных из файла обратно в память понадобилось бы применять класс
System.IO.BinaryReader
и снова вручную читать каждое значение, чтобы повторно сконфигурировать новый объект
UserPrefs
.

Все это выполнимо, но вы можете сэкономить значительное время за счет использования сериализации XML (eXtensible Markup Language — расширяемый язык разметки) или JSON (JavaScript Object Notation — запись объектов JavaScript). Каждый из указанных форматов состоит из пар "имя-значение", позволяя представлять открытое состояние объекта в одиночном блоке текста, который можно потреблять между платформами и языками программирования. В итоге полное открытое состояние объекта может быть сохранено с помощью лишь нескольких строк кода.


На заметку! Применение типа

BinaryFormatter
(
https://docs.microsoft.com/ru-ru/dotnet/api/system.runtime.serialization.formatters.binary.binaryformatter?view=net-5.0
), который рассматривался в предшествующих изданиях книги, сопряжено с высоким риском в плане безопасности, так что от него следует немедленно отказаться. Более защищенные альтернативы предусматривают использование классов
BinaryReader/BinaryWriter
для XML/JSON.


Сериализация объектов .NET Core упрощает сохранение объектов, но ее внутренний процесс довольно сложен. Например, когда объект сохраняется в потоке, все ассоциированные с ним открытые данные (т.е. данные базового класса и содержащиеся в нем объекты) также автоматически сериализируются. Следовательно, при попытке сериализации производного класса в игру вступают также все открытые данные по цепочке наследования. Вы увидите, что для представления множества взаимосвязанных объектов используется граф объектов.

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

System.IO.Stream
. Важно лишь то, чтобы последовательность данных корректно представляла состояние объектов внутри графа.

Роль графов объектов

Как упоминалось ранее, среда CLR будет учитывать все связанные объекты, чтобы обеспечить корректное сохранение данных, когда объект сериализируется. Такой набор связанных объектов называется графом объектов. Графы объектов предоставляют простой способ документирования взаимосвязи между множеством элементов. Следует отметить, что графы объектов не обозначают отношения "является" и "имеет" объектно-ориентированного программирования. Взамен стрелки в графе объектов можно трактовать как "требует" или "зависит от".

Каждый объект в графе получает уникальное числовое значение. Важно помнить, что числа, присвоенные объектам в графе, являются произвольными и не имеют никакого смысла для внешнего мира. После того как всем объектам назначены числовые значения, граф объектов может записывать набор зависимостей для каждого объекта.

В качестве примера предположим, что создано множество классов, которые моделируют автомобили. Существует базовый класс по имени

Car
, который "имеет" класс
Radio
. Другой класс по имени
JamesBondCar
расширяет базовый тип
Car
.

На рис. 20.1 показан возможный граф объектов, моделирующий такие отношения. При чтении графов объектов для описания соединяющих стрелок можно использовать выражение "зависит от" или "ссылается на". Таким образом, на рис. 20.1 видно, что класс

Car
ссылается на класс
Radio
(учитывая отношение "имеет" ),
JamesBondCar
ссылается на
Car
(из-за отношения "является" ), а также на
Radio
(поскольку наследует эту защищенную переменную-член).



Разумеется, исполняющая среда не рисует картинки в памяти для представления графа связанных объектов. Взамен отношение, показанное на рис. 20.1, представляется математической формулой, которая выглядит следующим образом:


[Car 3, ref 2], [Radio 2], [JamesBondCar 1, ref 3, ref 2]


Проанализировав формулу, вы заметите, что объект 3 (

Car
) имеет зависимость от объекта 2 (
Radio
). Объект 2 (
Radio
) — "одинокий волк", которому никто не нужен. Наконец, объект 1 (
JamesBondCar
) имеет зависимость от объекта 3, а также от объекта 2. В любом случае при сериализации или десериализации экземпляра
JamesBondCar
граф объектов гарантирует, что типы
Radio
и
Car
тоже примут участие в процессе.

Привлекательность процесса сериализации заключается в том, что граф, представляющий отношения между объектами, устанавливается автоматически "за кулисами". Как будет показано позже в главе, при желании в конструирование графа объектов можно вмешиваться, настраивая процесс сериализации с применением атрибутов и интерфейсов.

Создание примеров типов и написание операторов верхнего уровня

Создайте новый проект консольного приложения .NET 5 по имени

SimpleSerialize
. Добавьте в проект новый файл класса под названием
Radio.cs
со следующим кодом:


using System;

using System.Linq;

using System.Collections.Generic;

using System.Text.Json.Serialization;

using System.Xml;

using System.Xml.Serialization;


namespace SimpleSerialize

{

  public class Radio

  {

   public bool HasTweeters;

   public bool HasSubWoofers;

   public List StationPresets;

   public string RadioId = "XF-552RR6";

   public override string ToString()

   {

    var presets = string.Join(",",

         StationPresets.Select(i => i.ToString()).ToList());

    return $"HasTweeters:{HasTweeters}

         HasSubWoofers:{HasSubWoofers} Station 
Presets:{presets}";

   }

  }

}


Добавьте еще один файл класса по имени

Car.cs
и приведите его содержимое к такому виду:


using System;

using System.Text.Json.Serialization;

using System.Xml;

using System.Xml.Serialization;


namespace SimpleSerialize

{

  public class Car

  {

   public Radio TheRadio = new Radio();

   public bool IsHatchBack;

   public override string ToString()

    => $"IsHatchback:{IsHatchBack} Radio:{TheRadio.ToString()}";

  }

}


Затем добавьте очередной файл класса по имени

JamesBondCar.cs
и поместите в него следующий код:


using System;

using System.Text.Json.Serialization;

using System.Xml;

using System.Xml.Serialization;


namespace SimpleSerialize

{

  public class JamesBondCar : Car

  {

   public bool CanFly;

   public bool CanSubmerge;

   public override string ToString()

    => $"CanFly:{CanFly}, CanSubmerge:{CanSubmerge} {base.ToString()}";

  }

}


Ниже показан код финального файла класса

Person.cs
:


using System;

using System.Text.Json.Serialization;

using System.Xml;

using System.Xml.Serialization;


namespace SimpleSerialize

{

  public class Person

  {

   // Открытое поле.

   public bool IsAlive = true;

   /// Закрытое поле.

   private int PersonAge = 21;

   // Открытое свойство/закрытые данные.

   private string _fName = string.Empty;

   public string FirstName

   {

    get { return _fName; }

    set { _fName = value; }

   }

   public override string ToString() =>

   $"IsAlive:{IsAlive} FirstName:{FirstName} Age:{PersonAge} ";

  }

}


В заключение модифицируйте содержимое файла

Program.cs
, добавив следующий стартовый код:


using System;

using System.Collections.Generic;

using System.IO;

using System.Text.Json;

using System.Text.Json.Serialization;

using System.Xml;

using System.Xml.Serialization;

using SimpleSerialize;


Console.WriteLine("***** Fun with Object Serialization *****\n");

// Создать объект JamesBondCar и установить состояние.

JamesBondCar jbc = new()

{

  CanFly = true,

  CanSubmerge = false,

  TheRadio = new()

   {

    StationPresets = new() {89.3, 105.1, 97.1},

    HasTweeters = true

   }

};

Person p = new()

{

  FirstName = "James",

  IsAlive = true

};


Итак, все готово для того, чтобы приступить к исследованию сериализации XML и JSON.

Сериализация и десериализация с помощью XmlSerializer

Пространство имен

System.Xml
предоставляет класс
System.Xml.Serialization.XmlSerializer
. Этот форматер можно применять для сохранения открытого состояния заданного объекта в виде чистой XML-разметки. Важно отметить, что
XmlSerializer
требует объявления типа, который будет сериализироваться (или десериализироваться).

Управление генерацией данных XML

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

X
должно быть выражено в виде атрибута, но не подэлемента), которые обычно задаются посредством схемы XML или файла определения типа документа (Document-Type Definition — DTD).

По умолчанию класс

XmlSerializer
сериализирует все открытые поля/свойства как элементы XML, а не как атрибуты XML. Чтобы управлять генерацией результирующего документа XML с помощью класса
XmlSerializer
, необходимо декорировать типы любым количеством дополнительных атрибутов .NET Core из пространства имен
System.Xml.Serialization
. В табл. 20.12 описаны некоторые (но не все) атрибуты .NET Core, влияющие на способ кодирования данных XML в потоке.



Разумеется, для управления тем, как

XmlSerializer
генерирует результирующий XML-документ, можно использовать многие другие атрибуты .NET Core. Полные сведения ищите в описании пространства имен
System.Xml.Serialization
в документации по .NET Core.


На заметку! Класс

XmlSerializer
требует, чтобы все сериализируемые типы в графе объектов поддерживали стандартный конструктор (поэтому обязательно добавьте его обратно, если вы определяете специальные конструкторы).

Сериализация объектов с использованием XmlSerializer

Добавьте в свой файл

Program.cs
следующую локальную функцию:


static void SaveAsXmlFormat(T objGraph, string fileName)

{

  // В конструкторе XmlSerializer должен быть объявлен тип.

  XmlSerializer xmlFormat = new XmlSerializer(typeof(T));

  using (Stream fStream = new FileStream(fileName,

   FileMode.Create, FileAccess.Write, FileShare.None))

  {

   xmlFormat.Serialize(fStream, objGraph);

  }

}


Добавьте к операторам верхнего уровня такой код:


SaveAsXmlFormat(jbc, "CarData.xml");

Console.WriteLine("=> Saved car in XML format!");

SaveAsXmlFormat(p, "PersonData.xml");

Console.WriteLine("=> Saved person in XML format!");


Заглянув внутрь сгенерированного файла

CarData.xml
, вы обнаружите в нем показанные ниже XML-данные:


 xmlns:xsd= 
"http://www.w3.org/2001/XMLSchema" xmlns="http://www.MyCompany.com">

  

   true

   false

   

    89.3

    105.1

    97.1

   

   XF-552RR6

  

  false

  true

  false


Если вы хотите указать специальное пространство имен XML, которое уточняет

JamesBondCar
и кодирует значения
canFly
и
canSubmerge
в виде атрибутов XML, тогда модифицируйте определение класса
JamesBondCar
следующим образом:


[Serializable, XmlRoot(Namespace = "http://www.MyCompany.com")]

public class JamesBondCar : Car

{

  [XmlAttribute]

  public bool CanFly;

  [XmlAttribute]

  public bool CanSubmerge;

...

}


Вот как будет выглядеть результирующий XML-документ (обратите внимание на открывающий элемент

):


  xmlns:xsd="http://www.w3.org/2001/XMLSchema"

  CanFly="true" CanSubmerge="false" xmlns="http://www.MyCompany.com">

...


Исследуйте содержимое файла

PersonData.xml
:


 xmlns:xsd= 
"http://www.w3.org/2001/XMLSchema">

  true

  James


Важно отметить, что свойство

PersonAge
не сериализируется в XML. Это подтверждает, что сериализация XML учитывает только открытые свойства и поля.

Сериализация коллекций объектов

Теперь, когда вы видели, каким образом сохранять одиночный объект в потоке, давайте посмотрим,как сохранить набор объектов. Создайте локальную функцию, которая инициализирует список объектов

JamesBondCar
и сериализирует его в XML:


static void SaveListOfCarsAsXml()

{

  // Сохранить список List объектов JamesBondCar.

  List myCars = new()

   {

    new JamesBondCar{CanFly = true, CanSubmerge = true},

    new JamesBondCar{CanFly = true, CanSubmerge = false},

    new JamesBondCar{CanFly = false, CanSubmerge = true},

    new JamesBondCar{CanFly = false, CanSubmerge = false},

   };

  using (Stream fStream = new FileStream("CarCollection.xml",

   FileMode.Create, FileAccess.Write, FileShare.None))

  {

   XmlSerializer xmlFormat = new XmlSerializer(typeof(List));

   xmlFormat.Serialize(fStream, myCars);

  }

  Console.WriteLine("=> Saved list of cars!");

}


Наконец, добавьте следующую строку, чтобы задействовать новую функцию:


SaveListOfCarsAsXml(); 

Десериализация объектов и коллекций объектов

Десериализация XML буквально противоположна сериализации объектов (и коллекций объектов). Рассмотрим показанную далее локальную функцию для десериализации XML-разметки обратно в граф объектов. И снова обратите внимание, что тип, с которым нужно работать, должен быть передан конструктору

XmlSerializer
:


static T ReadAsXmlFormat(string fileName)

{

  // Создать типизированный экземпляр класса XmlSerializer.

  XmlSerializer xmlFormat = new XmlSerializer(typeof(T));

  using (Stream fStream = new FileStream(fileName, FileMode.Open))

  {

   T obj = default;

   obj = (T)xmlFormat.Deserialize(fStream);

   return obj;

  }

}


Добавьте к операторам верхнего уровня следующий код, чтобы восстановить XML-разметку обратно в объекты (или списки объектов):


JamesBondCar savedCar = ReadAsXmlFormat("CarData.xml");

Console.WriteLine("Original Car: {0}",savedCar.ToString());

Console.WriteLine("Read Car: {0}",savedCar.ToString());


List savedCars =

   ReadAsXmlFormat>("CarCollection.xml");

Сериализация и десериализация с помощью System.Text.Json

В пространстве имен

System.Text.Json
имеется класс
System.Text.Json.JsonSerializer
, который вы можете использовать для сохранения открытого состояния заданного объекта как данных JSON.

Управление генерацией данных JSON

По умолчанию

JsonSerializer
сериализирует все открытые свойства в виде пар "имя-значение" в формате JSON, применяя такие же имена (и регистр символов), как у имен свойств объекта. Вы можете управлять многими аспектами процесса сериализации с помощью наиболее часто используемых атрибутов, перечисленных в табл. 20.13.


Сериализация объектов с использованием JsonSerializer

Класс

JsonSerializer
содержит статические методы
Serialize()
, применяемые для преобразования объектов .NET Core (включая графы объектов) в строковое представление открытых свойств. Данные представляются как пары "имя-значение" в формате JSON. Добавьте в файл
Program.cs
показанную ниже локальную функцию:


static void SaveAsJsonFormat(T objGraph, string fileName)

{

  File.WriteAllText(fileName,

    System.Text.Json.JsonSerializer.Serialize(objGraph));

}


Добавьте к своим операторам верхнего уровня следующий код:


SaveAsJsonFormat(jbc, "CarData.json");

Console.WriteLine("=> Saved car in JSON format!");

SaveAsJsonFormat(p, "PersonData.json");

Console.WriteLine("=> Saved person in JSON format!");


Когда вы будете исследовать файлы JSON, вас может удивить тот факт, что файл

CarData.json
пуст (не считая пары фигурных скобок), а файл
PersonData.json
содержит только значение
Firstname
. Причина в том, что
JsonSerializer
по умолчанию записывает только открытые свойства, но не открытые поля. Проблема решается в следующем разделе.

Включение полей

Включить открытые поля в генерируемые данные JSON можно двумя способами. Первый способ предусматривает использование класса

JsonSerializerOptions
для сообщения
JsonSerialize
r о необходимости включить все поля. Второй способ предполагает модификацию классов за счет добавления атрибута
[Jsonlnclude]
к каждому открытому полю, которое должно быть включено в вывод JSON. Обратите внимание, что при первом способе (применение
JsonSerializationOptions
) будут включаться все открытые поля в графе объектов. Чтобы исключить отдельные открытые поля с использованием такого приема, вам придется использовать для этих полей атрибут
JsonExclude
.

Модифицируйте метод

SaveAsJsonFormat()
, как показано ниже:


static void SaveAsJsonFormat(T objGraph, string fileName)

{

  var options = new JsonSerializerOptions

  {

   IncludeFields = true,

  };

  File.WriteAllText(fileName,

    System.Text.Json.JsonSerializer.Serialize(objGraph, options));

}


Вместо применения класса

JsonSerializerOptions
того же результата можно достичь, обновив все открытые поля в примерах классов следующим образом (имейте в виду, что вы можете оставить в классах атрибуты
Xml
и они не будут помехой
JsonSerializer
):


// Radio.cs

public class Radio

{

  [JsonInclude]

  public bool HasTweeters;

  [JsonInclude]

  public bool HasSubWoofers;

  [JsonInclude]

  public List StationPresets;

  [JsonInclude]

  public string RadioId = "XF-552RR6";

  ...

}


// Car.cs

public class Car

{

  [JsonInclude]

  public Radio TheRadio = new Radio();

  [JsonInclude]

  public bool IsHatchBack;

  ...

}


// JamesBondCar.cs

public class JamesBondCar : Car

{

  [XmlAttribute]

  [JsonInclude]

  public bool CanFly;

  [XmlAttribute]

  [JsonInclude]

  public bool CanSubmerge;

  ...

}


// Person.cs

public class Person

{

  // Открытое поле.

  [JsonInclude]

  public bool IsAlive = true;

  ...

}


Теперь в результате запуска кода любым способом все открытые свойства и поля записываются в файл. Однако, заглянув содержимое файла, вы увидите, что данные JSON были записаны в минифицированном виде, т.е. в формате, в котором все незначащие пробельные символы и разрывы строк удаляются. Формат является стандартным во многом из-за широкого использования JSON для служб REST и уменьшения размера пакета данных при передаче информации между службами по HTTP/HTTPS.


На заметку! Поля для сериализации JSON обрабатываются точно так же, как для десериализации JSON. Если вы выбирали вариант включения полей при сериализации JSON, то также должны делать это при десериализации JSON.

Понятный для человека вывод данных JSON

В дополнение к варианту с включением открытых полей экземпляр класса

JsonSerializer
можно проинструктировать о необходимости записи данных JSON с отступами (для удобства чтения человеком). Модифицируйте свой метод, как показано ниже:


static void SaveAsJsonFormat(T objGraph, string fileName)

{

  var options = new JsonSerializerOptions

  {

   IncludeFields = true,

   WriteIndented = true

  };

  File.WriteAllText(fileName,

   System.Text.Json.JsonSerializer.Serialize(objGraph, options));

}


Заглянув в файл

CarData.json
, вы заметите, что вывод стал гораздо более читабельным:


{

  "CanFly": true,

  "CanSubmerge": false,

  "TheRadio": {

   "HasTweeters": true,

   "HasSubWoofers": false,

   "StationPresets": [

    89.3,

    105.1,

    97.1

   ],

   "RadioId": "XF-552RR6"

  },

  "IsHatchBack": false

}

Именование элементов JSON в стиле Pascal или в "верблюжьем" стиле

Стиль Pascal представляет собой формат, в котором первый символ и каждая важная часть имени начинается с символа в верхнем регистре. В предыдущем листинге данных JSON примером стиля Pascal служит

CanSubmerge
. В "верблюжьем" стиле, с другой стороны, для первого символа применяется нижний регистр, а все важные части имени начинаются с символа в верхнем регистре. Версия предыдущего примера в "верблюжьем" стиле выглядит как
canSubmerge
.

Почему это важно? Дело в том, что большинство популярных языков (в том числе С#) чувствительно к регистру. Таким образом,

CanSubmerge
и
canSubmerge
— два разных элемента. Повсюду в книге вы видели, что общепринятым стандартом для именования открытых конструкций в C# (классов, открытых свойств, функций и т.д.) является использование стиля Pascal. Тем не менее, в большинстве фреймворков JavaScript задействован "верблюжий" стиль. В итоге могут возникать проблемы при использовании .NET и C# для взаимодействия с другими системами, например, в случае передачи данных JSON туда и обратно между службами REST.

К счастью,

JsonSerializer
допускает настройку для поддержки большинства ситуаций, в том числе отличий в стилях именования. Если политика именования не указана, то
JsonSerializer
при сериализации и десериализации JSON будет применять стиль Pascal. Чтобы заставить процесс сериализации использовать "верблюжий" стиль, модифицируйте параметры, как показано ниже:


static void SaveAsJsonFormat(T objGraph, string fileName)

{

  JsonSerializerOptions options = new()

  {

   PropertyNamingPolicy = JsonNamingPolicy.CamelCase,

   IncludeFields = true,

   WriteIndented = true,

  };

  File.WriteAllText(fileName,

    System.Text.Json.JsonSerializer.Serialize(objGraph, options));

}


Теперь выпускаемые данные JSON будут представлены в "верблюжьем" стиле:


{

  "canFly": true,

  "canSubmerge": false,

  "theRadio": {

   "hasTweeters": true,

   "hasSubWoofers": false,

   "stationPresets": [

    89.3,

    105.1,

    97.1

   ],

   "radioId": "XF-552RR6"

  },

  "isHatchBack": false

}


При чтении данных JSON в коде C# по умолчанию поддерживается чувствительность к регистру символов. Политика именования соответствует настройке

PropertyNamingPolicy
, применяемой во время десериализации. Если ничего не установлено, тогда используется стандартный стиль Pascal. Установка
PropertyNamingPolicy
в
CamelCase
свидетельствует об ожидании того, что все входящие данные JSON должны быть представлены в "верблюжьем" стиле. Если политики именования не совпадают, то процесс десериализации (рассматриваемый далее) потерпит неудачу.

При десериализации JSON существует третий вариант — нейтральность к политике именования. Установка параметра

PropertyNameCaseInsensitive
в
true
приводит к тому, что
canSubmerge
и
CanSubmerge
будут десериализироваться. Вот код установки этого параметра:


JsonSerializerOptions options = new()

{

  PropertyNameCaseInsensitive = true,

  IncludeFields = true

};

Обработка чисел с помощью JsonSerializer

Стандартным режимом обработки чисел является

Strict
, который предусматривает, что числа будут сериализироваться как числа (без кавычек) и сериализироваться как числа (без кавычек). В классе
JsonSerializerOptions
имеется свойство
NumberHandling
, которое управляет чтением и записью чисел. В табл. 20.14 перечислены значения, доступные в перечислении
JsonNumberHandling
.



Перечисление

JsonNumberHandling
имеет атрибут
flags
, который делает возможным побитовое сочетание его значений. Например, если вы хотите читать строки (и числа) и записывать числа в виде строк, тогда применяйте следующую настройку:


JsonSerializerOptions options = new()

{

  ...

  NumberHandling = JsonNumberHandling.AllowReadingFromString &

            JsonNumberHandling.
WriteAsString

};


При таком изменении данные JSON, созданные для класса

Car
, будут выглядеть так:


{

  "canFly": true,

  "canSubmerge": false,

  "theRadio": {

   "hasTweeters": true,

   "hasSubWoofers": false,

   "stationPresets": [

    "89.3",

    "105.1",

    "97.1"

   ],

   "radioId": "XF-552RR6"

  },

  "isHatchBack": false

}

Потенциальные проблемы, связанные с производительностью, при использовании JsonSerializerOption

В случае применения класса

JsonSerializerOption
лучше всего создать единственный экземпляр и многократно использовать его повсюду в приложении. С учетом сказанного модифицируйте операторы верхнего уровня и методы, относящиеся к JSON, как показано ниже:


JsonSerializerOptions options = new()

{

   PropertyNameCaseInsensitive = true,

   PropertyNamingPolicy = JsonNamingPolicy.CamelCase,

   IncludeFields = true,

   WriteIndented = true,

   NumberHandling =

     JsonNumberHandling.AllowReadingFromString
 |

   JsonNumberHandling.
WriteAsString

};

SaveAsJsonFormat(options, jbc, "CarData.json");

Console.WriteLine("=> Saved car in JSON format!");


SaveAsJsonFormat(options, p, "PersonData.json");

Console.WriteLine("=> Saved person in JSON format!");


static void SaveAsJsonFormat(JsonSerializerOptions options,

  T objGraph, string fileName)

=> File.WriteAllText(fileName,

 System.Text.Json.JsonSerializer.Serialize(objGraph, 
options));

Стандартные настройки свойств JsonSerializer для веб-приложений

При построении веб-приложений вы можете применять специализированный конструктор для установки следующих свойств:


PropertyNameCaseInsensitive = true,

PropertyNamingPolicy = JsonNamingPolicy.CamelCase,

NumberHandling = JsonNumberHandling.AllowReadingFromString


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


JsonSerializerOptions options = new(JsonSerializerDefaults.Web)

{

  WriteIndented = true

};

Сериализация коллекций объектов

Сериализация коллекций объектов в данные JSON выполняется аналогично сериализации одиночного объекта. Поместите приведенную далее локальную функцию в конец операторов верхнего уровня:


static void SaveListOfCarsAsJson(JsonSerializerOptions options, string fileName)

{

   // Сохранить список List объектов JamesBondCar.

   List myCars = new()

   {

  new JamesBondCar { CanFly = true, CanSubmerge = true },

     new JamesBondCar { CanFly = true, CanSubmerge = false },

     new JamesBondCar { CanFly = false, CanSubmerge = true },

     new JamesBondCar { CanFly = false, CanSubmerge = false },

   };

   File.WriteAllText(fileName,

     System.Text.Json.JsonSerializer.Serialize(myCars, options));

   Console.WriteLine("=> Saved list of cars!");

}


В заключение добавьте следующую строку, чтобы задействовать новую функцию:


SaveListOfCarsAsJson(options, "CarCollection.json");

Десериализация объектов и коллекций объектов

Как и десериализация XML, десериализация JSON является противоположностью сериализации. Показанная ниже функция будет десериализировать данные JSON в тип, заданный при вызове обобщенной версии метода:


static T ReadAsJsonFormat(JsonSerializerOptions options,

  string fileName) =>

   System.Text.Json.JsonSerializer.Deserialize

     (File.ReadAllText(fileName), options);


Добавьте к операторам верхнего уровня следующий код для восстановления объектов (или списка объектов) из данных JSON:


JamesBondCar savedJsonCar =

   ReadAsJsonFormat(options, "CarData.json");

Console.WriteLine("Read Car: {0}", savedJsonCar.ToString());


List savedJsonCars =

   ReadAsJsonFormat>(options, 
"CarCollection.json");

Console.WriteLine("Read Car: {0}", savedJsonCar.ToString());

Резюме

Глава начиналась с демонстрации использования типов

Directory(Info)
и
File(Info)
. Вы узнали, что эти классы позволяют манипулировать физическими файлами и каталогами на жестком диске. Затем вы ознакомились с несколькими классами, производными от абстрактного класса
Stream
. Поскольку производные от
Stream
типы оперируют с низкоуровневым потоком байтов, пространство имен
System.IO
предлагает многочисленные типы средств чтения/записи (например,
StreamWriter
,
StringWriter
и
BinaryWriter
), которые упрощают процесс. Попутно вы взглянули на функциональность типа
DriveType
, научились наблюдать за файлами с применением типа
FileSystemWatcher
и выяснили, каким образом взаимодействовать с потоками в асинхронной манере.

В главе также рассматривались службы сериализации объектов. Вы видели, что платформа .NET Core использует граф объектов, чтобы учесть полный набор связанных объектов, которые должны сохраняться в потоке. В заключение вы поработали с сериализацией и десериализацией XML и JSON.

Глава 21 Доступ к данным с помощью ADO.NET

Внутри платформы .NET Core определено несколько пространств имен, которые позволяют взаимодействовать с реляционными базами данных. Все вместе эти пространства имен известны как ADO.NET. В настоящей главе вы сначала ознакомитесь с общей ролью инфраструктуры ADO.NET, а также основными типами и пространствами имен, после чего будет обсуждаться тема поставщиков данных ADO.NET. Платформа .NET Core поддерживает многочисленные поставщики данных (как являющиеся частью .NET Core, так и доступные от независимых разработчиков), каждый из которых оптимизирован для взаимодействия с конкретной системой управления базами данных (СУБД), например, Microsoft SQL Server, Oracle, MySQL и т.д.

Освоив общую функциональность, предлагаемую различными поставщиками данных, вы узнаете о паттерне фабрики поставщиков данных. Вы увидите, что с использованием типов из пространства имен

System.Data
(включая
System.Data.Common
, а также специфичные для поставщиков данных пространства имен вроде
Microsoft.Data.SqlClient
,
System.Data.Odbc
и доступное только в Windows пространство имен
System.Data.Oledb
) можно строить единственную кодовую базу, которая способна динамически выбирать поставщик данных без необходимости в повторной компиляции или развертывании кодовой базы приложения.

Далее вы научитесь работать напрямую с поставщиком баз данных SQL Server, создавая и открывая подключения для извлечения данных и затем вставляя, обновляя и удаляя данные, и ознакомитесь с темой транзакций базы данных. Наконец, вы запустите средство массового копирования SQL Server с применением ADO.NET для загрузки списка записей внутрь базы данных.


На заметку! Внимание в настоящей главе сконцентрировано на низкоуровневой инфраструктуре ADO.NET. Начиная с главы 22, будет раскрываться инфраструктура объектно-реляционного отображения (object-relational mapping — ORM) производства Microsoft под названием Entity Framework (EF) Core. Поскольку инфраструктура EF Core для доступа к данным внутренне использует ADO.NET, хорошее понимание принципов работы ADO.NET жизненно важно при поиске и устранении проблем при доступе к данным. Кроме того, существуют задачи, решить которые с помощью EF Core не удастся (такие как выполнение массового копирования данных в SQL), и для их решения требуются знания ADO.NET.

Сравнение ADO.NET и ADO

Если у вас есть опыт работы с предшествующей моделью доступа к данным на основе СОМ от Microsoft (Active Data Objects — ADO) и вы только начинаете использовать платформу .NET Core, то имейте в виду, что инфраструктура ADO. NET имеет мало общего с ADO помимо наличия в своем названии букв "A", "D" и "О". В то время как определенная взаимосвязь между двумя системами действительно существует (скажем, в обеих присутствует концепция объектов подключений и объектов команд), некоторые знакомые по ADO типы (например,

Recordset
) больше не доступны. Вдобавок вы обнаружите много новых типов, которые не имеют прямых эквивалентов в классической технологии ADO (скажем, адаптер данных).

Поставщики данных ADO.NET

В ADO.NET нет единого набора типов, которые взаимодействовали бы с множеством СУБД. Взамен ADO.NET поддерживает многочисленные поставщики данных, каждый из которых оптимизирован для взаимодействия со специфичной СУБД. Первое преимущество такого подхода в том, что вы можете запрограммировать специализированный поставщик данных для доступа к любым уникальным средствам отдельной СУБД. Второе преимущество связано с тем, что поставщик данных может подключаться непосредственно к механизму интересующей СУБД без какого-либо промежуточного уровня отображения.

Выражаясь просто, поставщик данных — это набор типов, определенных в отдельном пространстве имен, которым известно, как взаимодействовать с конкретным источником данных. Безотносительно к тому, какой поставщик данных применяется, каждый из них определяет набор классов, предоставляющих основную функциональность. В табл. 21.1 описаны распространенные основные базовые классы и ключевые интерфейсы, которые они реализуют.



Хотя конкретные имена основных классов будут отличаться между поставщиками данных (например,

SqlConnection
в сравнении с
OdbcConnection
), все они являются производными от того же самого базового класса (
DbConnection
в случае объектов подключения), реализующего идентичные интерфейсы (вроде
IDbConnection
). С учетом сказанного вполне корректно предположить, что после освоения работы с одним поставщиком данных остальные поставщики покажутся довольно простыми.


На заметку! Когда речь идет об объекте подключения в ADO.NET, то на самом деле имеется в виду специфичный тип, производный от

DbConnection
; не существует класса с буквальным именем "Connection". Та же идея остается справедливой в отношении объекта команды, объекта адаптера данных и т.д. По соглашению об именовании объекты в конкретном поставщике данных снабжаются префиксом в форме названия связанной СУБД (например,
SqlConnection
,
SqlCommand
и
SqlDataReader
).


На рис. 21.1 иллюстрируется место поставщиков данных в инфраструктуре ADO.NET. Клиентская сборка может быть приложением .NET Core любого типа: консольной программой, приложением Windows Forms, приложением WPF, веб-страницей ASP.NET Core, библиотекой кода .NET Core и т.д.



Кроме типов, показанных на рис. 21.1, поставщики данных будут предоставлять и другие типы; однако эти основные объекты определяют общие характеристики для всех поставщиков данных.

Поставщики данных ADO.NET

Подобно всем компонентам .NET Core поставщики данных поступают в виде пакетов NuGet. В их число входят поставщики, поддерживаемые Microsoft, но доступно и множество сторонних поставщиков. В табл. 21.2 описаны некоторые поставщики данных, поддерживаемые Microsoft.



Поставщик данных Microsoft SQL Server предлагает прямой доступ к хранилищам данных Microsoft SQL Server — и только к ним (включая SQL Azure). Пространство имен

Microsoft.Data.SqlClient
содержит типы, используемые поставщиком SQL Server.


На заметку! Хотя

System.Data.SqlClient
по-прежнему поддерживается, все усилия по разработке средств для взаимодействия с SQL Server (и с SQL Azure) сосредоточены на новой библиотеке поставщика
Microsoft.Data.SqlClient
.


Поставщик ODBC (

System.Data.Odbc
) обеспечивает доступ к подключениям ODBC. Типы ODBC, определенные в пространстве имен
System.Data.Odbc
, обычно полезны, только если требуется взаимодействие с СУБД, для которой отсутствует специальный поставщик данных .NET Core. Причина в том, что ODBC является широко распространенной моделью, которая предоставляет доступ к нескольким хранилищам данных.

Поставщик данных OLE DB, который состоит из типов, определенных в пространстве имен

System.Data.OleDb
, позволяет получать доступ к данным в любом хранилище данных, поддерживающем классический протокол OLE DB на основе СОМ. Из-за зависимости от СОМ этот поставщик будет работать только в среде Windows и считается устаревшим в межплатформенном мире .NET Core.

Типы из пространства имен System.Data

Из всех пространств имен, относящихся к ADO.NET,

System.Data
является "наименьшим общим знаменателем". Оно содержит типы, которые совместно используются всеми поставщиками данных ADO. NET независимо от лежащего в основе хранилища данных. В дополнение к нескольким исключениям, связанным с базами данных (например,
NoNullAllowedException
,
RowNotlnTableException
и
MissingPrimaryKeyException
), пространство имен
System.Data
содержит типы, которые представляют разнообразные примитивы баз данных (вроде таблиц, строк, столбцов и ограничений), а также общие интерфейсы, реализуемые классами поставщиков данных. В табл. 21.3 описаны основные типы, о которых следует знать.



Следующей задачей будет исследование основных интерфейсов

System.Data
на высоком уровне, что поможет лучше понять общую функциональность, предлагаемую любым поставщиком данных. В ходе чтения настоящей главы вы также ознакомитесь с конкретными деталями, а пока лучше сосредоточить внимание на общем поведении каждого типа интерфейса.

Роль интерфейса IDbConnection

Интерфейс

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


public interface IDbConnection : IDisposable

{

  string ConnectionString { get; set; }

  int ConnectionTimeout { get; }

  string Database { get; }

  ConnectionState State { get; }


  IDbTransaction BeginTransaction();

  IDbTransaction BeginTransaction(IsolationLevel il);

  void ChangeDatabase(string databaseName);

  void Close();

  IDbCommand CreateCommand();

  void Open();

  void Dispose();

}

Роль интерфейса IDbTransaction

Перегруженный метод

BeginTransaction()
, определенный в интерфейсе
IDbConnection
, предоставляет доступ к объекту транзакции поставщика. Члены, определенные интерфейсом
IDbTransaction
, позволяют программно взаимодействовать с транзакционным сеансом и лежащим в основе хранилищем данных:


public interface IDbTransaction : IDisposable

{

  IDbConnection Connection { get; }

  IsolationLevel IsolationLevel { get; }


  void Commit();

  void Rollback();

  void Dispose();

}

Роль интерфейса IDbCommand

Интерфейс

IDbCommand
будет реализован объектом команды поставщика данных. Подобно другим объектным моделям доступа к данным объекты команд позволяют программно манипулировать операторами SQL, хранимыми процедурами и параметризированными запросами. Объекты команд также обеспечивают доступ к типу чтения данных поставщика данных посредством перегруженного метода
ExecuteReader()
:


public interface IDbCommand : IDisposable

{

  string CommandText { get; set; }

  int CommandTimeout { get; set; }

  CommandType CommandType { get; set; }

  IDbConnection Connection { get; set; }

  IDbTransaction Transaction { get; set; }

  IDataParameterCollection Parameters { get; }

  UpdateRowSource UpdatedRowSource { get; set; }


  void Prepare();

  void Cancel();

  IDbDataParameter CreateParameter();

  int ExecuteNonQuery();

  IDataReader ExecuteReader();

  IDataReader ExecuteReader(CommandBehavior behavior);

  object ExecuteScalar();

  void Dispose();

}

Роль интерфейсов IDbDataParameter и IDataParameter

Обратите внимание, что свойство

Parameters
интерфейса
IDbCommand
возвращает строго типизированную коллекцию, реализующую интерфейс
IDataParameterCollection
, который предоставляет доступ к набору классов, совместимых с
IDbDataParameter
(например, объектам параметров):


public interface IDbDataParameter : IDataParameter

{

// Плюс члены интерфейса IDataParameter.

  byte Precision { get; set; }

  byte Scale { get; set; }

  int Size { get; set; }

}


Интерфейс

IDbDataParameter
расширяет
IDataParameter
с целью обеспечения дополнительных линий поведения:


public interface IDataParameter

{

  DbType DbType { get; set; }

  ParameterDirection Direction { get; set; }

  bool IsNullable { get; }

  string ParameterName { get; set; }

  string SourceColumn { get; set; }

  DataRowVersion SourceVersion { get; set; }

  object Value { get; set; }

}


Вы увидите, что функциональность интерфейсов I

DbDataParameter
и
IDataParameter
позволяет представлять параметры внутри команды SQL (включая хранимые процедуры) с помощью специфических объектов параметров ADO.NET вместо жестко закодированных строковых литералов.

Роль интерфейсов IDbDataAdapter и IDataAdapter

Адаптеры данных используются для помещения объектов

DataSet
в хранилище данных и извлечения их из него. Интерфейс
IDbDataAdapter
определяет следующий набор свойств, которые можно применять для поддержки операторов SQL, выполняющих связанные операции выборки, вставки, обновления и удаления:


public interface IDbDataAdapter : IDataAdapter

{

  // Плюс члены интерфейса IDataAdapter.

  IDbCommand SelectCommand { get; set; }

  IDbCommand InsertCommand { get; set; }

  IDbCommand UpdateCommand { get; set; }

  IDbCommand DeleteCommand { get; set; }

}


В дополнение к показанным четырем свойствам адаптер данных ADO.NET также получает линии поведения, определенные базовым интерфейсом, т.е.

IDataAdapter
. Интерфейс
IDataAdapter
определяет ключевую функцию типа адаптера данных: способность передавать объекты
DataSet
между вызывающим кодом и внутренним хранилищем данных, используя методы
Fill()
и
Update()
. Кроме того, интерфейс
IDataAdapter
позволяет с помощью свойства
TableMappings
сопоставлять имена столбцов базы данных с более дружественными к пользователю отображаемыми именами:


public interface IDataAdapter

{

  MissingMappingAction MissingMappingAction { get; set; }

  MissingSchemaAction MissingSchemaAction { get; set; }

  ITableMappingCollection TableMappings { get; }


  DataTable[] FillSchema(DataSet dataSet, SchemaType schemaType);

  int Fill(DataSet dataSet);

  IDataParameter[] GetFillParameters();

  int Update(DataSet dataSet);

}

Роль интерфейсов IDataReader и IDataRecord

Следующим основным интерфейсом является

IDataReader
, который представляет общие линии поведения, поддерживаемые отдельно взятым объектом чтения данных. После получения от поставщика данных ADO.NET объекта совместимого с
IDataReader
типа можно выполнять проход по результирующему набору в прямом направлении с поддержкой только чтения.


public interface IDataReader : IDisposable, IDataRecord

{

  // Плюс члены интерфейса IDataRecord

  int Depth { get; }

  bool IsClosed { get; }

  int RecordsAffected { get; }

  void Close();

  DataTable GetSchemaTable();

  bool NextResult();

  bool Read();

  Dispose();

}


Наконец, интерфейс

IDataReader
расширяет
IDataRecord
. В интерфейсе
IDataRecord
определено много членов, которые позволяют извлекать из потока строго типизированное значение, а не приводить к нужному типу экземпляр
System.Object
, полученный из перегруженного метода индексатора объекта чтения данных. Вот определение интерфейса
IDataRecord
:


public interface IDataRecord

{

  int FieldCount { get; }

  object this[ int i ] { get; }

  object this[ string name ] { get; }

  bool GetBoolean(int i);

  byte GetByte(int i);

  long GetBytes(int i, long fieldOffset, byte[] buffer,

   int bufferoffset, int length);

  char GetChar(int i);

  long GetChars(int i, long fieldoffset, char[] buffer,

   int bufferoffset, int length);

  IDataReader GetData(int i);

  string GetDataTypeName(int i);

  DateTime GetDateTime(int i);

  Decimal GetDecimal(int i);

  double GetDouble(int i);

  Type GetFieldType(int i);

  float GetFloat(int i);

  Guid GetGuid(int i);

  short GetInt16(int i);

  int GetInt32(int i);

  long GetInt64(int i);

  string GetName(int i);

  int GetOrdinal(string name);

  string GetString(int i);

  object GetValue(int i);

  int GetValues(object[] values);

  bool IsDBNull(int i);

}


На заметку! Метод

IDataReader.IsDBNull()
можно применять для программного выяснения, установлено ли указанное поле в
null
, прежде чем пытаться получить значение из объекта чтения данных (во избежание генерации исключения во время выполнения). Также вспомните, что язык C# поддерживает типы данных, допускающие
null
(см. главу 4), идеально подходящие для взаимодействия со столбцами, которые могут иметь значение
null
в таблице базы данных.

Абстрагирование поставщиков данных с использованием интерфейсов

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

IDbConnection
, то ему можно передавать любой объект подключения ADO.NET:


public static void OpenConnection(IDbConnection cn)

{

  // Открыть входное подключение для вызывающего кода.

  connection.Open();

}


На заметку! Использовать интерфейсы вовсе не обязательно; аналогичного уровня абстракции можно достичь путем применения абстрактных базовых классов (таких как

DbConnection
) в качестве параметров или возвращаемых значений. Однако использование интерфейсов вместо базовых классов является общепринятой практикой.


То же самое справедливо для возвращаемых значений. Создайте новый проект консольного приложения .NET Core по имени

MyConnectionFactory
. Добавьте в проект перечисленные ниже пакеты NuGet (пакет
OleDb
действителен только в Windows):


Microsoft.Data.SqlClient

System.Data.Common

System.Data.Odbc

System.Data.OleDb


Далее добавьте в проект новый файл по имени

DataProviderEnum.cs
со следующим кодом:


namespace MyConnectionFactory

{

  // Пакет OleDb предназначен только для Windows и в .NET Core не поддерживается.

  enum DataProviderEnum

  {

   SqlServer,

#if PC

   OleDb,

#endif

   Odbc,

   None

  }

}


Если на своей машине обработки вы работаете в среде Windows, тогда модифицируйте файл проекта, чтобы определить символ условной компиляции PC:


  PC


В случае использования Visual Studio щелкните правой кнопкой мыши на имени проекта и выберите в контекстном меню пункт Properties (Свойства). В открывшемся диалоговом окне Properties (Свойства) перейдите на вкладку Build (Сборка) и введите нужное значение в поле Conditional compiler symbols (Символы условной компиляции).

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


using System;

using System.Data;

using System.Data.Odbc;

#if PC

  using System.Data.OleDb;

#endif

using Microsoft.Data.SqlClient;

using MyConnectionFactory;


Console.WriteLine("
****
 Very Simple Connection Factory 
*****
\n");

Setup(DataProviderEnum.SqlServer);

#if PC

  Setup(DataProviderEnum.OleDb); // He поддерживается в macOS

#endif

Setup(DataProviderEnum.Odbc);

Setup(DataProviderEnum.None);

Console.ReadLine();


void Setup(DataProviderEnum provider)

{

  // Получить конкретное подключение.

  IDbConnection myConnection = GetConnection(provider);

  Console.WriteLine($"Your connection is a {myConnection?.GetType().Name ??  

"unrecognized type"}");

  // Открыть, использовать и закрыть подключение...

}


// Этот метод возвращает конкретный объект подключения

// на основе значения перечисления DataProvider.

IDbConnection GetConnection(DataProviderEnum dataProvider)

  => dataProvider switch

  {

   DataProviderEnum.SqlServer => new SqlConnection(),

#if PC

   // He поддерживается в macOS

   DataProviderEnum.OleDb => new OleDbConnection(),

#endif

   DataProviderEnum.Odbc => new OdbcConnection(),

   _ => null,

  };


Преимущество работы с общими интерфейсами из пространства имен

System.Data
(или на самом деле с абстрактными базовыми классами из пространства имен
System.Data.Common
) связано с более высокими шансами построить гибкую кодовую базу, которую со временем можно развивать. Например, в настоящий момент вы можете разрабатывать приложение, предназначенное для Microsoft SQL Server; тем не менее, вполне возможно, что спустя несколько месяцев ваша компания перейдет на другую СУБД. Если вы строите решение с жестко закодированными типами из пространства имен
System.Data.SqlClient
, которые специфичны для Microsoft SQL Server, тогда вполне очевидно, что в случае смены серверной СУБД код придется редактировать, заново компилировать и развертывать.

К настоящему моменту вы написали (довольно простой) код ADO.NET, который позволяет создавать различные типы объектов подключений, специфичные для поставщика. Тем не менее, получение объекта подключения — лишь один аспект работы с ADO.NET. Чтобы построить полезную библиотеку фабрики поставщиков данных, необходимо также учитывать объекты команд, объекты чтения данных, адаптеры данных, объекты транзакций и другие типы, связанные с данными. Создание подобной библиотеки кода не обязательно будет трудным, но все-таки потребует написания значительного объема кода и затрат времени.

Начиная с версии .NET 2.0, такая функциональность встроена прямо в библиотеки базовых классов .NET. В .NET Core эта функциональность была значительно обновлена.

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

Установка SQL Server и Azure Data Studio

На протяжении оставшегося материала главы мы будем выполнять запросы в отношении простой тестовой базы данных SQL Server по имени

AutoLot
. В продолжение автомобильной темы, затрагиваемой повсеместно в книге, база данных
AutoLot
будет содержать пять взаимосвязанных таблиц (
Inventory
,
Makes
,
Orders
,
Customers
и
CreditRisks
), которые хранят различные данные о заказах гипотетической компании по продаже автомобилей. Прежде чем погрузиться в детали, связанные с базой данных, вы должны установить SQL Server и IDE-среду SQL Server.


На заметку! Если ваша машина для разработки функционирует под управлением Windows и вы установили Visual Studio 2019, то уже имеете установленный экземпляр SQL Server Express (под названием

localdb
), который можно использовать для всех примеров в настоящей книге. В случае согласия работать с указанной версией можете сразу переходить в раздел "Установка IDE-среды SQL Server".

Установка SQL Server

В текущей главе и многих оставшихся главах книги вам будет нужен доступ к экземпляру SQL Server. Если вы применяете машину разработки, на которой установлена ОС, отличающаяся от Windows, и у вас нет доступного внешнего экземпляра SQL Server, или вы решили не использовать внешний экземпляр SQL Server, то можете запустить SQL Server внутри контейнера Docker на рабочей станции Мае или Linux. Контейнер Docker функционирует и на машинах Windows, поэтому вы можете выполнять примеры в книге с применением Docker независимо от выбранной ОС.

Установка SQL Server в контейнер Docker

В случае использования машины разработки, основанной не на Windows, и отсутствии доступного для примеров экземпляра SQL Server вы можете запустить SQL Server внутри контейнера Docker на рабочей станции Мае или Linux. Контейнер Docker работает также на машинах Windows, поэтому вы можете выполнять примеры в книге с применением Docker независимо от выбранной ОС.


На заметку! Контейнеризация является крупной темой, и в этой книге просто нет места, чтобы углубиться в подробности контейнеров или Docker. Книга охватывает ровно столько, чтобы вы могли проработать примеры.


Docker Desktop можно загрузить по ссылке

www.docker.com/get-started
. Загрузите и установите подходящую версию (Windows, Mac, Linux) для своей рабочей станции (вам понадобится учетная запись DockerHub). Удостоверьтесь, что при выдаче запроса выбраны контейнеры Linux.


На заметку! Выбранный вариант контейнера (Windows или Linux) — это ОС, функционирующая внутри контейнера, а не ОС, установленная на рабочей станции.

Получение образа и запуск SQL Server 2019

Контейнеры основаны на образах, а каждый образ представляет собой многоуровневый набор, из которого образован финальный продукт. Чтобы получить образ, необходимый для запуска SQL Server 2019 в контейнере, откройте окно командной строки и введите следующую команду:


docker pull mcr.microsoft.com/mssql/server:2019-latest


После загрузки образа на машину вам понадобится запустить SQL Server, для чего ввести следующую команду (целиком в одной строке):


docker run -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=P@ssw0rd"

 -p 5433:1433 --name AutoLot -d mcr.
microsoft.com/mssql/server:2019-latest


Предыдущая команда принимает лицензионное соглашение конечного пользователя. устанавливает пароль (в реальности будет использоваться строгий пароль), устанавливает отображение портов (порт 5433 на вашей машине отображается на стандартный порт для SQL Server в контейнере (1433)), указывает имя контейнера (

AutoLot
) и, наконец, информирует Docker о том, что должен применяться ранее загруженный образ.


На заметку! Это не те настройки, которые вы захотите использовать в реальной разработке. Информация о том, как изменить пароль системного администратора, и другие сведения доступны по ссылке

https://docs.microsoft.com/ru-ru/sql/linux/quickstart-install-connect-docker?view=sql-server-verl5&pivots=cs1-bash
.


Чтобы убедиться в том, что Docker функционирует, введите в окне командной строки команду

docker ps -а
. Вы увидите вывод наподобие показанного ниже (для краткости некоторые колонки опущены):


C:\Users\japik>docker ps -a

CONTAINER    ID    IMAGE      STATUS        PORTS      
NAMES

347475cfb823 mcr.microsoft.com/mssql/server:2019-latest Up 6 minutes 0.0.0.0:5433->1433/
tcp  AutoLot


Чтобы остановить контейнер, введите

docker stop 34747
, где цифры
34747
представляют собой первые пять символов идентификатора контейнера. Чтобы перезапустить контейнер, введите
docker start 34747
, не забыв обновить команду соответствующим началом идентификатора вашего контейнера.


На заметку! Вы также можете использовать с командами Docker CLI имя контейнера (

AutoLot
в этом примере), скажем,
docker start AutoLot
. Имейте в виду, что независимо от ОС команды Docker чувствительны к регистру символов.


Если вы желаете поработать с инструментальной панелью Docker, щелкните правой кнопкой мыши на значке Docker (в системном лотке) и выберите в контекстном меню пункт Dashboard (Инструментальная панель); вы должны увидеть образ, функционирующий на порте 5433. Наведите курсор мыши на имя образа и появятся команды для остановки, запуска и удаления (помимо прочих), как показано на рис. 21.2.


Установка SQL Server 2019

Вместе с Visual Studio 2019 устанавливается специальный экземпляр SQL Server (по имени (localdb)\mssqllocaldb). Если вы решили не использовать SQL Server Express LocalDB (или Docker) и работаете на машине Windows, тогда можете установить SQL Server 2019 Developer Edition. Продукт SQL Server 2019 Developer Edition бесплатен и доступен для загрузки по следующей ссылке:


https://www.microsoft.com/ru-ru/sql-server/sqlserver-downloads


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

Установка IDE-среды SQL Server

Azure Data Studio — это новая IDE-среда для использования с SQL Server. Она является бесплатной и межплатформенной, а потому будет работать под управлением Windows, Mac или Linux. Загрузить ее можно по ссылке:


https://www.microsoft.com/en-us/sql-server/sql-server-downloads


На заметку! Если вы работаете на машине Windows и отдаете предпочтение среде управления SQL Server (SQL Server Management Studio — SSMS), то можете загрузить последнюю версию по ссылке

https://docs.microsoft.com/ru-ru/sql/ssms/download-sql-server-management-studio-ssms
.

Подключение к SQL Server

После установки Azure Data Studio или SSMS настало время подключиться к экземпляру СУБД. В последующих разделах описано подключение к SQL Server в контейнере Docker или LocalDb. Если вы используете другой экземпляр SQL Server, тогда соответствующим образом обновите строку подключения.

Подключение к SQL Server в контейнере Docker

Чтобы подключиться к экземпляру SQL Server, функционирующему в контейнере Docker, сначала убедитесь, что он запущен и работает. Затем щелкните на элементе Create a connection (Создать подключение) в Azure Data Studio (рис. 21.3).



В диалоговом окне Connection Details (Детали подключения) введите

.,5433
в поле Server (Сервер). Точка задает текущий хост, а
,5433
— это порт, который вы указывали при создании экземпляра SQL Server в контейнере Docker. Введите
sa
в поле User name (Имя пользователя); пароль остается тем же самым, который вводился при создании экземпляра SQL Server. Имя в поле Name (Имя) является необязательным, но оно позволяет быстро выбирать данное подключение в последующих сеансах Azure Data Studio. Упомянутые параметры подключения показаны на рис. 21.4.


Подключение к SQL Server LocalDb

Чтобы подключиться к версии SQL Server Express LocalDb, установленной вместе с Visual Studio, приведите информацию о подключении в соответствие с показанной на рис. 21.5.


При подключении к LocalDb вы можете использовать аутентификацию Windows, поскольку экземпляр работает на той же машине, что и Azure Data Studio, и в том же контексте безопасности, что и текущий вошедший в систему пользователь.

Подключение к любому другому экземпляру SQL Server

Если вы подключаетесь к любому другому экземпляру SQL Server, тогда соответствующим образом обновите параметры подключения.

Восстановление базы данных AutoLot из резервной копии

Вместо того чтобы создавать базу данных с нуля, вы можете воспользоваться SSMS или Azure Data Studio для восстановления одной из резервных копий, содержащихся в папке

Chapter_21
хранилища GitHub для данной книги. Предлагаются две резервные копии: одна по имени
AutoLotWindows.ba_
рассчитана на применение на машине Windows (LocalDb, Windows Server и т.д.) и еще одна по имени
AutoLotDocker.ba_
предназначена для использования в контейнере Docker.


На заметку! GitHub по умолчанию игнорирует файлы с расширением

bak
. Прежде чем восстанавливать базу данных, вам придется переименовать расширение
Ьа
в
bak
.

Копирование файла резервной копии в имеющийся контейнер

Если вы работаете с SQL Server в контейнере Docker, то сначала должны скопировать файл резервной копии в контейнер. К счастью, Docker CLI предлагает механизм для взаимодействия с файловой системой контейнера. Первым делом создайте новый каталог для резервной копии с помощью следующей команды в окне командной троки на хост-машине:


docker exec -it AutoLot mkdir var/opt/mssql/backup


Структура пути должна соответствовать ОС контейнера (в данном случае Ubuntu), даже если хост-машина функционирует под управлением Windows. Затем скопируйте резервную копию с применением показанной ниже команды (укажите для местоположения файла

AutoLotDocker.bak
относительный или абсолютный путь на вашей локальной машине):


[Windows]

docker cp .\AutoLotDocker.bak AutoLot:var/opt/mssql/backup


[Non-Windows]

docker cp ./AutoLotDocker.bak AutoLot:var/opt/mssql/backup


Обратите внимание, что исходная структура каталогов соответствует хост-машине (в этом примере Windows), тогда как цель выглядит как имя контейнера и затем путь к каталогу (в формате целевой ОС).

Восстановление базы данных с помощью SSMS

Чтобы восстановить базу данных с применением SSMS, щелкните правой кнопкой мыши на узле Databases (Базы данных) в проводнике объектов и выберите в контекстном меню пункт Restore Database (Восстановить базу данных). Укажите вариант Device (Устройство) и щелкните на символе троеточия. Откроется диалоговое окно Select Backup Device (Выбор устройства с резервной копией).

Восстановление базы данных в экземпляр SQL Server (Docker)

Оставив в раскрывающемся списке Backup media type (Тип носителя резервной копии) выбранным вариант File (Файл), щелкните на кнопке Add (Добавить), перейдите к файлу

AutoLotDocker.bak
в контейнере и щелкните на кнопке ОК. Возвратившись в главное диалоговое окно восстановления, щелкните на кнопке ОК (рис. 21.6).


Восстановление базы данных в экземпляр SQL Server (Windows)

Оставив в раскрывающемся списке Backup media type выбранным вариант File, щелкните на кнопке Add, перейдите к файлу

AutoLotWindows.bak
и щелкните на кнопке ОК. Возвратившись в главное диалоговое окно восстановления, щелкните на кнопке ОК (рис. 21.7).


Восстановление базы данных с помощью Azure Data Studio

Чтобы восстановить базу данных с использованием Azure Data Studio, выберите в области Tasks (Задачи) вариант Restore (Восстановить). Укажите в раскрывающемся списке Restore from (Восстановить из) вариант Backup file (Файл резервной копии) и затем выберите только что скопированный файл. Целевая база данных и связанные поля заполнятся автоматически, как показано на рис. 21.8.



На заметку! Процесс восстановления версии Windows резервной копии посредством Azure Data Studio аналогичен. Понадобится просто скорректировать имя файла и пути.

Создание базы данных AutoLot

Весь этот раздел посвящен созданию базы данных

AutoLot
с применением Azure Data Studio. Если вы используете SSMS, то можете выполнить описанные здесь шаги, применяя либо приведенные SQL-сценарии, либо инструменты с графическим пользовательским интерфейсом. Если вы восстановили резервную копию, тогда переходите сразу в раздел "Модель фабрики поставщиков данных ADO.NET".


На заметку! Все файлы сценариев находятся в подпапке по имени

Scripts
внутри папки
Chapter_21
хранилища GitHub для данной книги.

Создание базы данных

Для создания базы данных AutoLot подключитесь к своему серверу баз данных с использованием Azure Data Studio. Откройте окно нового запроса, выбрав пункт меню FileNew Query (Файл►Новый запрос) или нажав комбинацию <Ctrl+N>, и введите следующие команды SQL:


USE [master]

GO

/****** Object:  Database [AutoLot50]   Script Date: 12/20/2020 01:48:05 ******/

CREATE DATABASE [AutoLot]

GO

ALTER DATABASE [AutoLot50] SET RECOVERY SIMPLE

GO


Кроме изменения режима восстановления на простой команда создает базу данных

AutoLot
с применением стандарных параметров SQL Server. Щелкните на кнопке Run (Выполнить) или нажмите <F5>, чтобы создать базу данных.

Создание таблиц

База данных

AutoLot
содержит пять таблиц:
Inventory
,
Makes
,
Customers
,
Orders
и
CreditRisks
.

Создание таблицы Inventory

После создания базы данных можно приступать к созданию таблиц. Первой таблицей будет

Inventory
. Откройте окно нового запроса и введите приведенные ниже команды SQL:


USE [AutoLot]

GO

CREATE TABLE [dbo].[Inventory](

   [Id] [int] IDENTITY(1,1) NOT NULL,

   [MakeId] [int] NOT NULL,

   [Color] [nvarchar](50) NOT NULL,

   [PetName] [nvarchar](50) NOT NULL,

   [TimeStamp] [timestamp] NULL,

 CONSTRAINT [PK_Inventory] PRIMARY KEY CLUSTERED

(

  [Id] ASC

) ON [PRIMARY]

) ON [PRIMARY]

GO


Щелкните на кнопке Run (или нажмите <F5>), чтобы создать таблицу

Inventory
.

Создание таблицы Makes

Таблица

Inventory
хранит внешний ключ в (пока еще не созданной) таблице
Makes
. Создайте новый запрос и введите следующие команды SQL для создания таблицы
Makes
:


USE [AutoLot]

GO

CREATE TABLE [dbo].[Makes](

[Id] [int] IDENTITY(1,1) NOT NULL,

  [Name] [nvarchar](50) NOT NULL,

  [TimeStamp] [timestamp] NULL,

 CONSTRAINT [PK_Makes] PRIMARY KEY CLUSTERED

(

  [Id] ASC

) ON [PRIMARY]

) ON [PRIMARY]

GO


Щелкните на кнопке Run (или нажмите <F5>), чтобы создать таблицу

Makes
.

Создание таблицы Customers

Таблица

Customers
будет хранить список покупателей. Создайте новый запрос и введите представленные далее команды SQL:


USE [AutoLot]

GO

CREATE TABLE [dbo].[Customers](

  [Id] [int] IDENTITY(1,1) NOT NULL,

  [FirstName] [nvarchar](50) NOT NULL,

  [LastName] [nvarchar](50) NOT NULL,

  [TimeStamp] [timestamp] NULL,

 CONSTRAINT [PK_Customers] PRIMARY KEY CLUSTERED

(

  [Id] ASC

) ON [PRIMARY]

) ON [PRIMARY]

GO


Щелкните на кнопке Run (или нажмите <F5>), чтобы создать таблицу

Customers
.

Создание таблицы Orders

Создаваемая следующей таблица

Orders
будет использоваться для представления автомобилей, заказанных покупателями. Создайте новый запрос, введите показанные ниже команды SQL и щелкните на кнопке Run (или нажмите <F5>):


USE [AutoLot]

GO

CREATE TABLE [dbo].[Orders](

  [Id] [int] IDENTITY(1,1) NOT NULL,

  [CustomerId] [int] NOT NULL,

  [CarId] [int] NOT NULL,

  [TimeStamp] [timestamp] NULL,

 CONSTRAINT [PK_Orders] PRIMARY KEY CLUSTERED

(

  [Id] ASC

) ON [PRIMARY]

) ON [PRIMARY]

GO

Создание таблицы CreditRisks

Финальная таблица

CreditRisks
будет применяться для представления покупателей, связанных с кредитным риском. Создайте новый запрос, введите следующие команды SQL и щелкните на кнопке Run (или нажмите <F5>):


USE [AutoLot]

GO

CREATE TABLE [dbo].[CreditRisks](

  [Id] [int] IDENTITY(1,1) NOT NULL,

  [FirstName] [nvarchar](50) NOT NULL,

  [LastName] [nvarchar](50) NOT NULL,

  [CustomerId] [int] NOT NULL,

  [TimeStamp] [timestamp] NULL,

 CONSTRAINT [PK_CreditRisks] PRIMARY KEY CLUSTERED

(

   [Id] ASC

) ON [PRIMARY]

) ON [PRIMARY]

GO

Создание отношений между таблицами

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

Создание отношения между таблицами Inventory и Makes

Откройте окно нового запроса, введите показанные далее команды SQL и щелкните на кнопке Run (или нажмите <F5>):


USE [AutoLot]

GO

CREATE NONCLUSTERED INDEX [IX_Inventory_MakeId] ON [dbo].[Inventory]

(

  [MakeId] ASC

) ON [PRIMARY]

GO

ALTER TABLE [dbo].[Inventory]

  WITH CHECK ADD  CONSTRAINT [FK_Make_Inventory] FOREIGN 

KEY([MakeId])

REFERENCES [dbo].[Makes] ([Id])

GO

ALTER TABLE [dbo].[Inventory] CHECK CONSTRAINT [FK_Make_Inventory]

GO

Создание отношения между таблицами Inventory и Orders

Откройте окно нового запроса, введите следующие команды SQL и щелкните на кнопке Run (или нажмите <F5>):


USE [AutoLot]

GO

CREATE NONCLUSTERED INDEX [IX_Orders_CarId] ON [dbo].[Orders]

(

  [CarId] ASC

) ON [PRIMARY]

GO

ALTER TABLE [dbo].[Orders]

  WITH CHECK ADD  CONSTRAINT [FK_Orders_Inventory] FOREIGN 

KEY([CarId])

REFERENCES [dbo].[Inventory] ([Id])

GO

ALTER TABLE [dbo].[Orders] CHECK CONSTRAINT [FK_Orders_Inventory]

GO

Создание отношения между таблицами Orders и Customers

Откройте окно нового запроса, введите приведенные ниже команды SQL и щелкните на кнопке Run (или нажмите <F5>):


USE [AutoLot]

GO

CREATE UNIQUE NONCLUSTERED INDEX [IX_Orders_CustomerId_CarId] ON [dbo].[Orders]

(

  [CustomerId] ASC,

  [CarId] ASC

) ON [PRIMARY]

GO

ALTER TABLE [dbo].[Orders]

  WITH CHECK ADD  CONSTRAINT [FK_Orders_Customers] FOREIGN 

KEY([CustomerId])

REFERENCES [dbo].[Customers] ([Id])

ON DELETE CASCADE

GO

ALTER TABLE [dbo].[Orders] CHECK CONSTRAINT [FK_Orders_Customers]

GO

Создание отношения между таблицами Customers и CreditRisks

Откройте окно нового запроса, введите приведенные ниже команды SQL и щелкните на кнопке Run (или нажмите <F5>):


USE [AutoLot]

GO

CREATE NONCLUSTERED INDEX [IX_CreditRisks_CustomerId] ON [dbo].[CreditRisks]

(

  [CustomerId] ASC

) ON [PRIMARY]

GO

ALTER TABLE [dbo].[CreditRisks]

  WITH CHECK ADD  CONSTRAINT [FK_CreditRisks_Customers] 

FOREIGN KEY([CustomerId])

REFERENCES [dbo].[Customers] ([Id])

ON DELETE CASCADE

GO

ALTER TABLE [dbo].[CreditRisks] CHECK CONSTRAINT [FK_CreditRisks_Customers]

GO


На заметку! Наличие столбцов

FirstName/LastName
и отношение с таблицей преследует здесь только демонстрационные цели. В главе 23 они будут задействованы в более интересном сценарии.

Создание хранимой процедуры GetPetName

Позже в главе вы узнаете, как использовать ADO.NET для вызова хранимых процедур. Возможно, вам уже известно, что хранимые процедуры — это подпрограммы кода, хранящиеся внутри базы данных, которые выполняют какие-то действия. Подобно методам C# хранимые процедуры могут возвращать данные или просто работать с данными, ничего не возвращая. Добавьте одиночную хранимую процедуру, которая будет возвращать дружественное имя автомобиля на основе предоставленного

carId
. Откройте окно нового запроса и введите следующую команду SQL:


USE [AutoLot]

GO

CREATE PROCEDURE [dbo].[GetPetName]

@carID int,

@petName nvarchar(50) output

AS

SELECT @petName = PetName from dbo.Inventory where Id = @carID

GO


Щелкните на кнопке Run (или нажмите <F5>), чтобы создать хранимую процедуру.

Добавление тестовых записей

В отсутствие данных базы данных не особо интересны, поэтому удобно иметь сценарии, которые способны быстро загрузить тестовые записи в базу данных.

Записи таблицы Makes

Создайте новый запрос и выполните показанные далее операторы SQL для добавления записей в таблицу

Makes
:


USE [AutoLot]

GO

SET IDENTITY_INSERT [dbo].[Makes] ON

INSERT INTO [dbo].[Makes] ([Id], [Name]) VALUES (1, N'VW')

INSERT INTO [dbo].[Makes] ([Id], [Name]) VALUES (2, N'Ford')

INSERT INTO [dbo].[Makes] ([Id], [Name]) VALUES (3, N'Saab')

INSERT INTO [dbo].[Makes] ([Id], [Name]) VALUES (4, N'Yugo')

INSERT INTO [dbo].[Makes] ([Id], [Name]) VALUES (5, N'BMW')

INSERT INTO [dbo].[Makes] ([Id], [Name]) VALUES (6, N'Pinto')

SET IDENTITY_INSERT [dbo].[Makes] OFF

Записи таблицы Inventory

Чтобы добавить записи в таблицу

Inventory
, создайте новый запрос и выполните следующие операторы SQL:


USE [AutoLot]

GO

SET IDENTITY_INSERT [dbo].[Inventory] ON

GO

INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName])

 VALUES (1, 1, N'Black', 
N'Zippy')

INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName])

 VALUES (2, 2, N'Rust', 
N'Rusty')

INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName])

 VALUES (3, 3, N'Black', 
N'Mel')

INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName])

 VALUES (4, 4, N'Yellow', 
N'Clunker')

INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName])

 VALUES (5, 5, N'Black', 
N'Bimmer')

INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName])

 VALUES (6, 5, N'Green', 
N'Hank')

INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName])

 VALUES (7, 5, N'Pink', 
N'Pinky')

INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName])

 VALUES (8, 6, N'Black', 
N'Pete')

INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName])

 VALUES (9, 4, N'Brown', 

N'Brownie')SET IDENTITY_INSERT [dbo].[Inventory] OFF

GO

Добавление тестовых записей в таблицу Customers

Чтобы добавить записи в таблицу

Customers
, создайте новый запрос и выполните представленные ниже операторы SQL:


USE [AutoLot]

GO

SET IDENTITY_INSERT [dbo].[Customers] ON

INSERT INTO [dbo].[Customers] ([Id], [FirstName], [LastName])

 VALUES (1, N'Dave', N'Brenner')

INSERT INTO [dbo].[Customers] ([Id], [FirstName], [LastName])

 VALUES (2, N'Matt', N'Walton')

INSERT INTO [dbo].[Customers] ([Id], [FirstName], [LastName])

 VALUES (3, N'Steve', N'Hagen')

INSERT INTO [dbo].[Customers] ([Id], [FirstName], [LastName])

 VALUES (4, N'Pat', N'Walton')

INSERT INTO [dbo].[Customers] ([Id], [FirstName], [LastName])

 VALUES (5, N'Bad', N'Customer')

SET IDENTITY_INSERT [dbo].[Customers] OFF

Добавление тестовых записей в таблицу Orders

Теперь добавьте данные в таблицу

Orders
. Откройте окно нового запроса, введите следующую команду SQL и щелкните на кнопке Run (или нажмите <F5>):


USE [AutoLot]

GO

SET IDENTITY_INSERT [dbo].[Orders] ON

INSERT INTO [dbo].[Orders] ([Id], [CustomerId], [CarId]) VALUES (1, 1, 5)

INSERT INTO [dbo].[Orders] ([Id], [CustomerId], [CarId]) VALUES (2, 2, 1)

INSERT INTO [dbo].[Orders] ([Id], [CustomerId], [CarId]) VALUES (3, 3, 4)

INSERT INTO [dbo].[Orders] ([Id], [CustomerId], [CarId]) VALUES (4, 4, 7)

SET IDENTITY_INSERT [dbo].[Orders] OFF

Добавление тестовых записей в таблицу CreditRisks

Финальный шаг связан с добавлением данных в таблицу

CreditRisks
. Откройте окно нового запроса, введите следующую команду SQL и щелкните на кнопке Run (или нажмите <F5>):


USE [AutoLot]

GO

SET IDENTITY_INSERT [dbo].[CreditRisks] ON

INSERT INTO [dbo].[CreditRisks] ([Id], [FirstName], [LastName],

  [CustomerId]) VALUES (1, 

N'Bad', N'Customer', 5)

SET IDENTITY_INSERT [dbo].[CreditRisks] OFF


На этом создание базы данных

AutoLot
завершается. Конечно, она очень далека от базы данных реального приложения, но будет успешно удовлетворять всем нуждам текущей главы, а также добавляться в главах, посвященных Entity Framework Core. Располагая тестовой базой данных, можно приступить к погружению в детали, касающиеся модели фабрики поставщиков данных ADO.NET.

Модель фабрики поставщиков данных ADO.NET

Модель фабрики поставщиков данных .NET Core позволяет строить единую кодовую базу, используя обобщенные типы доступа к данным. Чтобы разобраться в реализации фабрики поставщиков данных, вспомните из табл. 21.1, что все классы внутри поставщика данных являются производными от тех же самых базовых классов, определенных внутри пространства имен

System.Data.Common
:

DbCommand
— абстрактный базовый класс для всех классов команд;

DbConnection
— абстрактный базовый класс для всех классов подключений;

DbDataAdapter
— абстрактный базовый класс для всех классов адаптеров данных;

DbDataReader
— абстрактный базовый класс для всех классов чтения данных;

DbParameter
— абстрактный базовый класс для всех классов параметров;

DbTransaction
— абстрактный базовый класс для всех классов транзакций.


Каждый поставщик данных, совместимый с .NET Core, содержит класс, производный от

System.Data.Common.DbProviderFactory
. В этом базовом классе определено несколько методов, которые извлекают объекты данных, специфичные для поставщика. Вот члены класса
DbProviderFactory
:


public abstract class DbProviderFactory

{

  public virtual bool CanCreateDataAdapter { get;};

  public virtual bool CanCreateCommandBuilder { get;};

  public virtual DbCommand CreateCommand();

  public virtual DbCommandBuilder CreateCommandBuilder();

  public virtual DbConnection CreateConnection();

  public virtual DbConnectionStringBuilder CreateConnectionStringBuilder();

  public virtual DbDataAdapter CreateDataAdapter();

  public virtual DbParameter CreateParameter();

  public virtual DbDataSourceEnumerator CreateDataSourceEnumerator();

}


Чтобы получить производный от

DbProviderFactory
тип для вашего поставщика данных, каждый поставщик предоставляет статическое свойство, используемое для возвращения корректного типа. Для возвращения версии SQL Server поставщика
DbProviderFactory
применяйте следующий код:


// Получить фабрику для поставщика данных SQL.

DbProviderFactory sqlFactory =

   Microsoft.Data.SqlClient.SqlClientFactory.Instance;


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

DbProviderFactory
, которая возвращает конкретную разновидность
DbProviderFactory
на основе настройки в файле
appsettings.json
для приложения. Вскоре вы узнаете, как это делать, а пока после получения фабрики для поставщика данных можно получить связанные с ним объекты данных (например, объекты подключений, команд и чтения данных).

Полный пример фабрики поставщиков данных

В качестве завершенного примера создайте новый проект консольного приложения C# (по имени

DataProviderFactory
), которое выводит инвентарный список автомобилей из базы данных
AutoLot
. В начальном примере логика доступа к данным будет жестко закодирована прямо в сборке
DataProviderFactory.exe
(чтобы излишне не усложнять код). По мере изучения материалов настоящей главы вы узнаете более эффективные способы решения задачи.

Начните с добавления нового элемента

ItemGroup
и пакетов
Microsoft.Extensions.Configuration.Json
,
System.Data.Common
,
System.Data.Odbc
,
System.Data.OleDb
и
Microsoft.Data.SqlClient
в файл проекта:


dotnet add DataProviderFactory package Microsoft.Data.SqlClient

dotnet add DataProviderFactory package System.Data.Common

dotnet add DataProviderFactory package System.Data.Odbc

dotnet add DataProviderFactory package System.Data.OleDb

dotnet add DataProviderFactory package Microsoft.Extensions.Configuration.Json


Определите символ условной компиляции PC (в случае применения Windows):


  PC


Далее добавьте новый файл по имени

DataProviderEnum.cs
и модифицируйте его код, как показано ниже:


namespace DataProviderFactory

{ 

  // OleDb поддерживается только в Windows, но не в .NET Core. 

  enum DataProviderEnum 

  { 

   SqlServer, 

 #if PC 

   OleDb, 

#endif 

   Odbc 

  } 

} 


Добавьте в проект новый файл JSON по имени

appsettings.json
и измените его содержимое следующим образом (обновите строки подключения в соответствии с имеющейся средой):


{

  "ProviderName": "SqlServer",

  //"ProviderName": "OleDb",

  //"ProviderName": "Odbc",

  "SqlServer": {

   // Для localdb используйте @"Data Source=(localdb)\

   // mssqllocaldb;Integrated Security=true; 

   Initial Catalog=AutoLot"

   "ConnectionString": "Data Source=.,5433;User Id=sa;Password=P@ssw0rd;Initial 

    Catalog=AutoLot"

  },

  "Odbc": {

   // Для localdb используйте @"Driver={ODBC Driver 17 for SQL Server};

   Server=(localdb)\mssqllocaldb;Database=AutoLot;Trusted_Connection=Yes";

    "ConnectionString": "Driver={ODBC Driver 17 for SQL Server};

   Server=localhost,5433; 

    Database=AutoLot;UId=sa;Pwd=P@ssw0rd;"

  },

  "OleDb": {

   // Для localdb используйте @"Provider=SQLNCLI11;

   // Data Source=(localdb)\mssqllocaldb;Initial 

   Catalog=AutoLot;Integrated Security=SSPI"),

   "ConnectionString": "Provider=SQLNCLI11;Data Source=.,5433;

     User Id=sa;Password=P@ssw0rd; 

   Initial Catalog=AutoLot;"

  }

}


Сообщите MSBuild о необходимости копировать файл JSON в выходной каталог при каждой компиляции. Модифицируйте файл проекта, как показано ниже:


  

   Always

  


На заметку! Элемент

CopyToOutputDirectory
чувствителен к наличию пробельных символов. Убедитесь, что пробелы вокруг слова
Always
отсутствуют.


Теперь, располагая подходящим файлом

appsettings.json
, вы можете читать значения
provider
и
connectionstring
с использованием конфигурации .NET Core. Начните с обновления операторов
using
в верхней части файла
Program.cs
:


using System;

using System.Data.Common;

using System.Data.Odbc;

#if PC

  using System.Data.OleDb;

#endif

using System.IO;

using Microsoft.Data.SqlClient;

using Microsoft.Extensions.Configuration;


Очистите весь код в

Program.cs
и добавьте взамен следующий код:


using System;

using System.Data.Common;

using System.Data.Odbc;

#if PC

  using System.Data.OleDb;

#endif

using System.IO;

using Microsoft.Data.SqlClient;

using Microsoft.Extensions.Configuration;

using DataProviderFactory;


Console.WriteLine("***** Fun with Data Provider Factories *****\n");

var (provider, connectionString) = GetProviderFromConfiguration();

DbProviderFactory factory = GetDbProviderFactory(provider);

// Теперь получить объект подключения.

using (DbConnection connection = factory.CreateConnection())

{

  if (connection == null)

  {

   Console.WriteLine($"Unable to create the connection object");

          // He удалось создать объект подключения

   return;

  }


  Console.WriteLine($"Your connection object is a: {connection.GetType().Name}");

  connection.ConnectionString = connectionString;

  connection.Open();


  // Создать объект команды.

  DbCommand command = factory.CreateCommand();

  if (command == null)

  {

   Console.WriteLine($"Unable to create the command object");

          // He удалось создать объект команды

   return;

  }

   Console.WriteLine($"Your command object is a: {command.GetType().Name}");

  command.Connection = connection;

  command.CommandText =

   "Select i.Id, m.Name From Inventory i inner join Makes m on m.Id =

    i.MakeId ";


  // Вывести данные с помощью объекта чтения данных.

  using (DbDataReader dataReader = command.ExecuteReader())

  {

   Console.WriteLine($"Your data reader object is a:

    {dataReader.GetType().Name}");

   Console.WriteLine("\n***** Current Inventory *****");

   while (dataReader.Read())

   {

    Console.WriteLine($"-> Car #{dataReader["Id"]} is a

     {dataReader["Name"]}.");

   }

  }

}

Console.ReadLine();


Добавьте приведенный далее код в конец файла

Program.cs
. Эти методы читают конфигурацию, устанавливают корректное значение
DataProviderEnum
, получают строку подключения и возвращают экземпляр
DbProviderFactory
:


static DbProviderFactory GetDbProviderFactory(DataProviderEnum provider)

  => provider switch

{

  DataProviderEnum.SqlServer => SqlClientFactory.Instance,

  DataProviderEnum.Odbc => OdbcFactory.Instance,

#if PC

  DataProviderEnum.OleDb => OleDbFactory.Instance,

#endif

  _ => null

};


static (DataProviderEnum Provider, string ConnectionString)

  GetProviderFromConfiguration()

{

  IConfiguration config = new ConfigurationBuilder()

   .SetBasePath(Directory.GetCurrentDirectory())

   .AddJsonFile("appsettings.json", true, true)

   .Build();

  var providerName = config["ProviderName"];

  if (Enum.TryParse

   (providerName, out DataProviderEnum provider))

  {

   return (provider,config[$"{providerName}:ConnectionString"]);

  };

  throw new Exception("Invalid data provider value supplied.");

}


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

Inventory
базы данных
AutoLot
:


*****

 Fun with Data Provider Factories 

*****

Your connection object is a: SqlConnection

Your command object is a: SqlCommand

Your data reader object is a: SqlDataReader

*****

 Current Inventory 

*****

-> Car #1 is a VW.

-> Car #2 is a Ford.

-> Car #3 is a Saab.

-> Car #4 is a Yugo.

-> Car #9 is a Yugo.

-> Car #5 is a BMW.

-> Car #6 is a BMW.

-> Car #7 is a BMW.

-> Car #8 is a Pinto.


Измените файл настроек, чтобы указать другого поставщика. Код выберет связанную строку подключения и произведет тот же вывод, что и ранее, исключая специфичную для типа информацию.

Конечно, в зависимости от опыта работы с ADO.NET у вас может не быть полного понимания того, что в действительности делают объекты подключений, команд и чтения данных. Не вдаваясь в детали, пока просто запомните, что модель фабрики поставщиков данных ADO.NET позволяет строить единственную кодовую базу, которая способна потреблять разнообразные поставщики данных в декларативной манере.

Потенциальный недостаток модели фабрики поставщиков данных

Хотя модель фабрики поставщиков данных характеризуется высокой мощностью, вы должны обеспечить применение в кодовой базе только типов и методов, общих для всех поставщиков, посредством членов абстрактных базовых классов. Следовательно, при разработке кодовой базы вы ограничены членами

DbConnection
,
DbCommand
и других типов из пространства имен
System.Data.Common
.

С учетом сказанного вы можете прийти к заключению, что такой обобщенный подход предотвращает прямой доступ к дополнительным возможностям отдельной СУБД. Если вы должны быть в состоянии обращаться к специфическим членам лежащего в основе поставщика (например,

SqlConnection
), то можете воспользоваться явным приведением:


if (connection is SqlConnection sqlConnection)

{

  // Вывести информацию об используемой версии SQL Server.

  WriteLine(sqlConnection.ServerVersion);

}


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


На заметку! Инфраструктура Entity Framework Core и ее поддержка внедрения зависимостей значительно упрощает построение библиотек доступа к данным, которым необходим доступ к разрозненным источникам данных.


Первый пример завершен, и теперь можно углубляться в детали работы с ADO.NET.

Погружение в детали объектов подключений, команд и чтения данных

Как было показано в предыдущем примере, ADO.NET позволяет взаимодействовать с базой данных с помощью объектов подключения, команд и чтения данных имеющегося поставщика данных. Для более глубокого понимания упомянутых объектов в ADO.NET будет создан расширенный пример.

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

1. Создать, сконфигурировать и открыть объект подключения.

2. Создать и сконфигурировать объект команды, указав объект подключения в аргументе конструктора или через свойство

Connection
.

3. Вызвать метод

ExecuteReader()
на сконфигурированном объекте команды.

4. Обработать каждую запись с применением метода

Read()
объекта чтения данных.


Для начала создайте новый проект консольного приложения по имени

AutoLot.DataReader
и добавьте пакет
Microsoft.Data.SqlClient
. Ниже приведен полный код внутри
Program.cs
(с последующим анализом):


using System;

using Microsoft.Data.SqlClient;

Console.WriteLine("***** Fun with Data Readers *****\n");

// Создать и открыть подключение.

using (SqlConnection connection = new SqlConnection())

{

  connection.ConnectionString =

   @" Data Source=.,5433;User Id=sa;Password=P@ssw0rd;Initial Catalog=AutoLot";

   connection.Open();

  // Создать объект команды SQL.

  string sql =

   @"Select i.id, m.Name as Make, i.Color, i.Petname

      FROM Inventory i

      INNER JOIN Makes m on m.Id = i.MakeId";

  SqlCommand myCommand = new SqlCommand(sql, connection);

  // Получить объект чтения данных с помощью ExecuteReader().

  using (SqlDataReader myDataReader = myCommand.ExecuteReader())

  {

   // Пройти в цикле по результатам.

   while (myDataReader.Read())

   {

    Console.WriteLine($"-> Make: {myDataReader["Make"]},

       PetName: {myDataReader 

     ["PetName"]}, Color: {myDataReader["Color"]}.");

   }

  }

}

Console.ReadLine();

Работа с объектами подключений

При работе с поставщиком данных первым делом понадобится установить сеанс с источником данных, используя объект подключения (производного от

DbConnection
типа). Объекты подключений .NET Core обеспечиваются форматированной строкой подключения, которая содержит несколько пар "имя-значение", разделенных точками с запятой. Такая информация идентифицирует имя машины, к которой нужно подключиться, требуемые настройки безопасности, имя базы данных на машине и другие специфичные для поставщика сведения.

Из приведенного выше кода можно сделать вывод, что имя

Initial Catalog
относится к базе данных, с которой необходимо установить сеанс. Имя
Data Source
идентифицирует имя машины, где находится база данных. Здесь применяется строка
"., 5433"
, которая ссылается на хост-машину (точка соответствует
localhost
), и порт 5433, который представляет собой порт контейнера Docker, отображенный на порт SQL Server. Если бы вы использовали другой экземпляр, то определили бы свойство как
имя_машины,порт\экземпляр
. Например,
MYSERVER\SQLSERVER2019
означает, что
MYSERVER
— имя сервера, на котором функционирует SQL Server, что применяется стандартный порт и что
SQLSERVER2019
представляет собой имя экземпляра. Если машина является локальной по отношению к разработке, тогда можете использовать для имени сервера точку (
.
) или маркер (
localhost
). В случае стандартного экземпляра SQL Server имя экземпляра не указывается. Скажем, если вы создаете базу данных
AutoLot
в установленной копии Microsoft SQL Server, настроенной как стандартный экземпляр на вашем локальном компьютере, то могли бы применять
"Data Source=localhost"
.

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

Integrated Security
установлено в
true
, то для аутентификации и авторизации используется текущая учетная запись Windows.

Когда строка подключения готова, можно вызывать метод

Open()
для установления подключения к базе данных. В дополнение к членам
Connectionstring
,
Open()
и
Close()
объект подключения предоставляет несколько членов, которые позволяют конфигурировать дополнительные настройки подключения, такие как таймаут и транзакционная информация. В табл. 21.4 кратко описаны избранные члены базового класса
DbConnection
.



Свойства типа

DbConnection
обычно по своей природе допускают только чтение и полезны, только если требуется получить характеристики подключения во время выполнения. Когда необходимо переопределить стандартные настройки, придется изменить саму строку подключения. Например, в следующей строке подключения время таймаута
Connect Timeout
устанавливается равным 30 секундам вместо стандартных 15 секунд (для SQL Server):


using(SqlConnection connection = new SqlConnection())

{

  connection.ConnectionString =

   @" Data Source=.,5433;User Id=sa;Password=P@ssw0rd;

   Initial Catalog=AutoLot;Connect 
Timeout=30";

  connection.Open();

}


Следующий код выводит детали о переданной ему строке подключения

SqlConnection
:


static void ShowConnectionStatus(SqlConnection connection)

{

  // Вывести различные сведения о текущем объекте подключения.

  Console.WriteLine("***** Info about your connection *****");

  Console.WriteLine($@"Database location:

   {connection.DataSource}");     // Местоположение базы данных

  Console.WriteLine($"Database name: {connection.Database}");

                     // Имя базы данных

  Console.WriteLine($@"Timeout:

   {connection.ConnectionTimeout}");  // Таймаут

  Console.WriteLine($"Connection state:

   {connection.State}\n");       // Состояние подключения

}


Большинство этих свойств понятно без объяснений, но свойство

State
требует специального упоминания. Ему можно присвоить любое значение из перечисления
ConnectionState
:


public enum ConnectionState

{

  Broken,

  Closed,

  Connecting,

  Executing,

  Fetching,

  Open

}


Однако допустимыми значениями

ConnectionState
будут только
ConnectionState.Open
,
ConnectionState.Connecting
и
ConnectionState.Closed
(остальные члены перечисления зарезервированы для будущего использования). Кроме того, закрывать подключение всегда безопасно, даже если его состоянием в текущий момент является
ConnectionState.Closed
.

Работа с объектами ConnectionStringBuilder

Работа со строками подключения в коде может быть утомительной, т.к. они часто представлены в виде строковых литералов, которые в лучшем случае трудно обрабатывать и контролировать на предмет ошибок. Совместимые с .NET Core поставщики данных поддерживают объекты построителей строк подключения, которые позволяют устанавливать пары "имя-значение" с применением строго типизированных свойств. Взгляните на следующую модификацию текущего кода:


var connectionStringBuilder = new SqlConnectionStringBuilder

{

  InitialCatalog = "AutoLot",

  DataSource = ".,5433",

  UserID = "sa",

  Password = "P@ssw0rd",

  ConnectTimeout = 30

};

  connection.ConnectionString =

   connectionStringBuilder.ConnectionString;


В этой версии создается экземпляр класса

SqlConnectionStringBuilder
, соответствующим образом устанавливаются его свойства, после чего с использованием свойства
ConnectionString
получается внутренняя строка. Обратите внимание, что здесь применяется стандартный конструктор типа. При желании объект построителя строки подключения для поставщика данных можно также создать, передав в качестве отправной точки существующую строку подключения (что может быть удобно, когда значения динамически читаются из внешнего источника). После наполнения объекта начальными строковыми данными отдельные пары "имя-значение" можно изменять с помощью связанных свойств.

Работа с объектами команд

Теперь, когда вы лучше понимаете роль объекта подключения, следующей задачей будет выяснение, каким образом отправлять SQL-запросы базе данных. Тип

SqlCommand
(производный от
DbCommand
) является объектно-ориентированным представлением SQL-запроса, имени таблицы или хранимой процедуры. Тип команды указывается с использованием свойства
CommandType
, которое принимает любое значение из перечисления
CommandType
:


public enum CommandType

{

  StoredProcedure,

  TableDirect,

  Text // Стандартное значение.

}


При создании объекта команды SQL-запрос можно указывать как параметр конструктора или устанавливать свойство

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


// Создать объект команды посредством аргументов конструктора.

string sql =

   @"Select i.id, m.Name as Make, i.Color, i.Petname

     FROM Inventory i

     INNER JOIN Makes m on m.Id = i.MakeId";

SqlCommand myCommand = new SqlCommand(sql, connection);


// Создать еще один объект команды через свойства.

SqlCommand testCommand = new SqlCommand();

testCommand.Connection = connection;

testCommand.CommandText = sql;


Учтите, что в текущий момент вы еще фактически не отправили SQL-запрос базе данных

AutoLot
, а только подготовили состояние объекта команды для будущего использования.

В табл. 21.5 описаны некоторые дополнительные члены типа

DbCommand
.


Работа с объектами чтения данных

После установления активного подключения и объекта команды SQL следующим действием будет отправка запроса источнику данных. Как вы наверняка догадались, это можно делать несколькими путями. Самый простой и быстрый способ получения информации из хранилища данных предлагает тип

DbDataReader
(реализующий интерфейс
IDataReader
). Вспомните, что объекты чтения данных представляют поток данных, допускающий только чтение в прямом направлении, который возвращает по одной записи за раз. Таким образом, объекты чтения данных полезны, только когда лежащему в основе хранилищу данных отправляются SQL-операторы выборки.

Объекты чтения данных удобны, если нужно быстро пройти по большому объему данных без необходимости иметь их представление в памяти. Например, в случае запрашивания 20 000 записей из таблицы с целью их сохранения в текстовом файле помещение такой информации в объект

DataSet
приведет к значительному расходу памяти (поскольку
DataSet
хранит полный результат запроса в памяти).

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

Объекты чтения данных получаются из объекта команды с применением вызова

ExecuteReader()
. Объект чтения данных представляет текущую запись, прочитанную из базы данных. Он имеет метод индексатора (например, синтаксис
[]
в языке С#), который позволяет обращаться к столбцам текущей записи. Доступ к конкретному столбцу возможен либо по имени, либо по целочисленному индексу, начинающемуся с нуля.

В приведенном ниже примере использования объекта чтения данных задействован метод

Read()
, с помощью которого выясняется, когда достигнут конец записей (в случае чего он возвращает
false
). Для каждой прочитанной из базы данных записи с применением индексатора типа выводится модель, дружественное имя и цвет каждого автомобиля. Обратите внимание, что сразу после завершения обработки записей вызывается метод
Close()
, которые освобождает объект подключения.


...

// Получить объект чтения данных посредством ExecuteReader().

using(SqlDataReader myDataReader = myCommand.ExecuteReader())

{

  // Пройти в цикле по результатам.

  while (myDataReader.Read())

  {

   WriteLine($"-> Make: { myDataReader["Make"]},

     PetName: { myDataReader["PetName"]}, 

     Color: { myDataReader["Color"]}.");

  }

}

ReadLine();


Индексатор объекта чтения данных перегружен для приема либо значения

string
(имя столбца), либо значения
int
(порядковый номер столбца). Таким образом, текущую логику объекта чтения можно сделать яснее (и избежать жестко закодированных строковых имен), внеся следующие изменения (обратите внимание на использование свойства
FieldCount
):


while (myDataReader.Read())

{

  for (int i = 0; i < myDataReader.FieldCount; i++)

  {

   Console.Write(i != myDataReader.FieldCount - 1

    ? $"{myDataReader.GetName(i)} = {myDataReader.GetValue(i)}, "

    : $"{myDataReader.GetName(i)} = {myDataReader.GetValue(i)} ");

  }

  Console.WriteLine();

}


Если в настоящий момент скомпилировать проект и запустить его на выполнение, то должен отобразиться список всех автомобилей из таблицы Inventory базы данных

AutoLot
. В следующем выводе показано несколько начальных записей:


***** Fun with Data Readers *****

***** Info about your connection *****

Database location: .,5433

Database name: AutoLot

Timeout: 30

Connection state: Open

id = 1, Make = VW, Color = Black, Petname = Zippy

id = 2, Make = Ford, Color = Rust, Petname = Rusty

id = 3, Make = Saab, Color = Black, Petname = Mel

id = 4, Make = Yugo, Color = Yellow, Petname = Clunker

id = 5, Make = BMW, Color = Black, Petname = Bimmer

id = 6, Make = BMW, Color = Green, Petname = Hank

id = 7, Make = BMW, Color = Pink, Petname = Pinky

id = 8, Make = Pinto, Color = Black, Petname = Pete

id = 9, Make = Yugo, Color = Brown, Petname = Brownie

Получение множества результирующих наборов с использованием объекта чтения данных

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

Inventory
, а также все строки из таблицы
Customers
, тогда можете указать два SQL-оператора
Select
, разделив их точкой с запятой:


sql += ";Select * from Customers;";


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


После получения объекта чтения данных можно выполнить проход по каждому результирующему набору, используя метод

NextResult()
. Обратите внимание, что автоматически возвращается первый результирующий набор. Таким образом, если нужно прочитать все строки каждой таблицы, тогда можно построить следующую конструкцию итерации:


do

{

  while (myDataReader.Read())

  {

   for (int i = 0; i < myDataReader.FieldCount; i++)

   {

    Console.Write(i != myDataReader.FieldCount - 1

     ? $"{myDataReader.GetName(i)} = {myDataReader.GetValue(i)}, "

     : $"{myDataReader.GetName(i)} = {myDataReader.GetValue(i)} ");

   }

   Console.WriteLine();

  }

  Console.WriteLine();

} while (myDataReader.NextResult());


К этому моменту вы уже должны лучше понимать функциональность, предлагаемую объектами чтения данных. Не забывайте, что объект чтения данных способен обрабатывать только SQL-операторы

Select
; его нельзя применять для изменения существующей таблицы базы данных с использованием запросов
Insert
,
Update
или
Delete
. Модификация существующей базы данных требует дальнейшего исследования объектов команд.

Работа с запросами создания обновления и удаления

Метод

ExecuteReader()
извлекает объект чтения данных, который позволяет просматривать результаты SQL-оператора Select с помощью потока информации, допускающего только чтение в прямом направлении. Однако если необходимо отправить операторы SQL, которые в итоге модифицируют таблицу данных (или любой другой отличающийся от запроса оператор SQL, такой как создание таблицы либо выдача разрешений), то потребуется вызов метода
ExecuteNonQuery()
объекта команды. В зависимости от формата текста команды указанный единственный метод выполняет вставки, обновления и удаления.


На заметку! Говоря формально, "отличающийся от запроса" означает оператор SQL, который не возвращает результирующий набор. Таким образом, операторы Select являются запросами, тогда как

Insert
,
Update
и
Delete
— нет. С учетом сказанного метод
ExecuteNonQuery()
возвращает значение
int
, которое представляет количество строк, затронутых операторами, а не новый набор записей.


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

create
,
read
,
update
,
delete
— CRUD). Далее вы научитесь пользоваться такой функциональностью, применяя вызовы
ExecuteNonQuery()
.

Начните с создания нового проекта библиотеки классов C# по имени

AutoLot.DAL
(сокращение от AutoLot Data Access Layer — уровень доступа к данным
AutoLot
), удалите стандартный файл класса и добавьте в проект пакет
Microsoft.Data.SqlClient
.

Перед построением класса, который будет управлять операциями с данными, сначала понадобится создать класс С#, представляющий запись из таблицы

Inventory
со связанной информацией Make.

Создание классов Car и CarViewModel

В современных библиотеках доступа к данным применяются классы (обычно называемые моделями или сущностями), которые используются для представления и транспортировки данных из базы данных. Кроме того, классы могут применяться для представления данных, которое объединяет две и большее количество таблиц, делая данные более значимыми. Сущностные классы используются при работе с каталогом базы данных (для операторов обновления), а классы модели представления применяются для отображения данных в осмысленной манере. В следующей главе вы увидите, что такие концепции являются основой инфраструктур объектно-реляционного отображения (object relational mapping — ORM) вроде Entity Framework Core, но пока вы просто собираетесь создать одну модель (для низкоуровневой строки хранилища) и одну модель представления (объединяющую строку хранилища и связанные данные в таблице

Makes
). Добавьте в проект новую папку по имени
Models
и поместите в нее два файла,
Car.cs
и
CarViewModel.cs
, со следующим кодом:


// Car.cs

namespace AutoLot.Dal.Models

{

  public class Car

  {

   public int Id { get; set; }

   public string Color { get; set; }

   public int MakeId { get; set; }

   public string PetName { get; set; }

   public byte[] TimeStamp {get;set;}

  }

}


// CarViewModel.cs

namespace AutoLot.Dal.Models

{

  public class CarViewModel : Car

  {

   public string Make { get; set; }

  }

}


На заметку! Если вы не знакомы с типом данных

TimeStamp
в SQL Server (который отображается на
byte[]
в С#), то беспокоиться об этом не стоит. Просто знайте, что он используется для проверки параллелизма на уровне строк и раскрывается вместе с Entity Framework Core.


Новые классы будут применяться вскоре.

Добавление класса InventoryDal

Далее добавьте новую папку по имени

DataOperations
. Поместите в нее файл класса по имени
InventoryDal.cs
и измените класс на
public
. В этом классе будут определены разнообразные члены, предназначенные для взаимодействия с таблицей
Inventory
базы данных
AutoLot
. Наконец, импортируйте следующие пространства имен:


using System; 

using System.Collections.Generic;

using System.Data;

using AutoLot.Dal.Models;

using Microsoft.Data.SqlClient;

Добавление конструкторов

Создайте конструктор, который принимает строковый параметр (

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


namespace AuoLot.Dal.DataOperations

{

  public class InventoryDal

  {

   private readonly string _connectionString;

   public InventoryDal() : this(

    @"Data Source=.,5433;User Id=sa;Password=P@ssw0rd;

   Initial Catalog=AutoLot")

   {

   }

   public InventoryDal(string connectionString)

    => _connectionString = connectionString;

  }

}

Открытие и закрытие подключения

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

(OpenConnection()
) и еще один для закрытия подключения (
CloseConnection()
). В методе
CloseConnection()
проверьте состояние подключения и если оно не закрыто, тогда вызовите метод
Close()
на объекте подключения. Вот как выглядит код:


private SqlConnection _sqlConnection = null;

private void OpenConnection()

{

  _sqlConnection = new SqlConnection

  {

   ConnectionString = _connectionString

  };

  _sqlConnection.Open();

}


private void CloseConnection()

{

  if (_sqlConnection?.State != ConnectionState.Closed)

  {

   _sqlConnection?.Close();

  }

}


Ради краткости в большинстве методов класса

InventoryDal
не будут применяться блоки
try/catch
для обработки возможных исключений, равно как не будут генерироваться специальные исключения для сообщения о разнообразных проблемах при выполнении (скажем, неправильно сформированная строка подключения). Если бы строилась библиотека доступа к данным производственного уровня, то определенно пришлось бы использовать приемы структурированной обработки исключений (как объяснялось в главе 7), чтобы учесть любые аномалии времени выполнения.

Добавление реализации IDisposable

Добавьте к определению класса интерфейс

IDisposable
:


public class InventoryDal : IDisposable

{

  ...

}


Затем реализуйте шаблон освобождения, вызывая

Dispose()
на объекте
SqlConnection
:


bool _disposed = false;

protected virtual void Dispose(bool disposing)

{

  if (_disposed)

  {

   return;

  }

  if (disposing)

  {

   _sqlConnection.Dispose();

  }

  _disposed = true;

}


public void Dispose()

{

  Dispose(true);

  GC.SuppressFinalize(this);

}

Добавление методов выборки

Для начала объедините имеющиеся сведения об объектах команд, чтения данных и обобщенных коллекциях, чтобы получить записи из таблицы

Inventory
. Как было показано в начале главы, объект чтения данных в поставщике делает возможной выборку записей с использованием механизма, который реализует только чтение в прямом направлении с помощью метода
Read()
. В этом примере свойство
CommandBehavior
класса
DataReader
настроено на автоматическое закрытие подключения, когда закрывается объект чтения данных. Метод
GetAllInventory()
возвращает экземпляр
List
, представляющий все данные в таблице
Inventory
:


public List GetAllInventory()

{

  OpenConnection();

  // Здесь будут храниться записи.

  List inventory = new List();


  // Подготовить объект команды.

  string sql =

   @"SELECT i.Id, i.Color, i.PetName,m.Name as Make

      FROM Inventory i

      INNER JOIN Makes m on m.Id = i.MakeId";

  using SqlCommand command =

   new SqlCommand(sql, _sqlConnection)

   {

    CommandType = CommandType.Text

   };

  command.CommandType = CommandType.Text;

  SqlDataReader dataReader =

   command.ExecuteReader(CommandBehavior.CloseConnection);

  while (dataReader.Read())

  {

   inventory.Add(new CarViewModel

   {

    Id = (int)dataReader["Id"],

    Color = (string)dataReader["Color"],

    Make = (string)dataReader["Make"],

    PetName = (string)dataReader["PetName"]

   });

  }

  dataReader.Close();

  return inventory;

}


Следующий метод выборки получает одиночный объект

CarViewModel
на основе значения
CarId
:


public CarViewModel GetCar(int id)

{

  OpenConnection();

  CarViewModel car = null;

  // Параметры должны применяться по причинам, связанным с безопасностью.

  string sql =

  $@"SELECT i.Id, i.Color, i.PetName,m.Name as Make

      FROM Inventory i

      INNER JOIN Makes m on m.Id = i.MakeId

      WHERE i.Id = {id}";

  using SqlCommand command =

   new SqlCommand(sql, _sqlConnection)

   {

    CommandType = CommandType.Text

   };

  SqlDataReader dataReader =

   command.ExecuteReader(CommandBehavior.CloseConnection);

  while (dataReader.Read())

  {

   car = new CarViewModel

   {

    Id = (int) dataReader["Id"],

    Color = (string) dataReader["Color"],

    Make = (string) dataReader["Make"],

    PetName = (string) dataReader["PetName"]

   };

  }

  dataReader.Close();

  return car;

}


На заметку! Помещение пользовательского ввода внутрь низкоуровневых операторов SQL, как делалось здесь, обычно считается неудачной практикой. Позже в главе код будет модифицирован для использования параметров.

Вставка новой записи об автомобиле

Вставка новой записи в таблицу Inventory сводится к построению SQL-оператора

Insert
(на основе пользовательского ввода), открытию подключения, вызову метода
ExecuteNonQuery()
с применением объекта команды и закрытию подключения. Увидеть вставку в действии можно, добавив к типу
InventoryDal
открытый метод по имени
InsertAuto()
, который принимает три параметра, отображаемые на не связанные с идентичностью столбцы таблицы
Inventory
(
Color
,
Make
и
PetName
). Указанные аргументы используются при форматировании строки для вставки новой записи. И, наконец, для выполнения итогового оператора SQL применяется объект
SqlConnection
.


public void InsertAuto(string color, int makeId, string petName)

{

  OpenConnection();

  // Сформатировать и выполнить оператор SQL.

  string sql = $"Insert Into Inventory (MakeId, Color, PetName) Values ('{makeId}', 

'{color}', '{petName}')";

  // Выполнить, используя наше подключение.

  using (SqlCommand command = new SqlCommand(sql, _sqlConnection))

  {

   command.CommandType = CommandType.Text;

   command.ExecuteNonQuery();

  }

  CloseConnection();

}


Приведенный выше метод принимает три значения для

Car
и работает при условии, что вызывающий код передает значения в правильном порядке. Более совершенный метод использует
Car
, чтобы стать строго типизированным, гарантируя тем самым, что все свойства передаются методу в корректном порядке.

Создание строго типизированного метода InsertCar()

Добавьте в класс

InventoryDal
еще одну версию метода
InsertAuto()
, которая принимает в качестве параметра
Car
:


public void InsertAuto(Car car)

{

  OpenConnection();

  // Сформатировать и выполнить оператор SQL.

  string sql = "Insert Into Inventory (MakeId, Color, PetName) Values " +

   $"('{car.MakeId}', '{car.Color}', '{car.PetName}')";


  // Выполнить, используя наше подключение.

  using (SqlCommand command = new SqlCommand(sql, _sqlConnection))

  {

   command.CommandType = CommandType.Text;

   command.ExecuteNonQuery();

  }

  CloseConnection();

}

Добавление логики удаления

Удаление существующей записи не сложнее вставки новой записи. В отличие от метода

InsertAuto()
на этот раз вы узнаете о важном блоке
try/catch
, который обрабатывает возможную попытку удалить запись об автомобиле, уже заказанном кем-то из таблицы
Customers
. Стандартные параметры
INSERT
и
UPDATE
для внешних ключей по умолчанию предотвращают удаление зависимых записей в связанных таблицах. Когда предпринимается попытка подобного удаления, генерируется исключение
SqlException
.

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

InventoryDal
следующий метод:


public void DeleteCar(int id)

{

  OpenConnection();

  // Получить идентификатор автомобиля, подлежащего удалению,

  // и удалить запись о нем.

  string sql = $"Delete from Inventory where Id = '{id}'";

  using (SqlCommand command = new SqlCommand(sql, _sqlConnection))

  {

   try

   {

    command.CommandType = CommandType.Text;

    command.ExecuteNonQuery();

   }

   catch (SqlException ex)

   {

    Exception error = new Exception("Sorry! That car is on order!", ex);

    throw error;

   }

  }

  CloseConnection();

}

Добавление логики обновления

Когда речь идет об обновлении существующей записи в таблице

Inventory
, первым делом потребуется решить, какие характеристики будет позволено изменять вызывающему коду: цвет автомобиля, его дружественное имя, модель или все перечисленное? Один из способов предоставления вызывающему коду полной гибкости заключается в определении метода, принимающего параметр типа
string
, который представляет любой оператор SQL, но в лучшем случае это сопряжено с риском.

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


public void UpdateCarPetName(int id, string newPetName)

{

  OpenConnection();

  // Получить идентификатор автомобиля для модификации дружественного имени.

  string sql = $"Update Inventory Set PetName = '{newPetName}'

          Where Id = '{id}'";

  using (SqlCommand command = new SqlCommand(sql, _sqlConnection))

  {

   command.ExecuteNonQuery();

  }

  CloseConnection();

}

Работа с параметризированным и объектами команд

В настоящий момент внутри логики вставки, обновления и удаления для типа

InventoryDal
используются жестко закодированные строковые литералы, представляющие каждый запрос SQL. В параметризированных запросах параметры SQL являются объектами, а не простыми порциями текста. Трактовка запросов SQL в более объектно-ориентированной манере помогает сократить количество опечаток (учитывая, что свойства строго типизированы). Вдобавок параметризированные запросы обычно выполняются значительно быстрее запросов в виде строковых литералов, т.к. они подвергаются разбору только однажды (а не каждый раз, когда строка с запросом SQL присваивается свойству
CommandText
). Параметризированные запросы также содействуют в защите против атак внедрением в SQL (хорошо известная проблема безопасности доступа к данным).

Для поддержки параметризированных запросов объекты команд ADO.NET содержат коллекцию индивидуальных объектов параметров. По умолчанию коллекция пуста, но в нее можно вставить любое количество объектов параметров, которые отображаются на параметры-заполнители в запросе SQL. Чтобы ассоциировать параметр внутри запроса SQL с членом коллекции параметров в объекте команды, параметр запроса SQL необходимо снабдить префиксом в виде символа

@
(во всяком случае, когда применяется Microsoft SQL Server; не все СУБД поддерживают такую систему обозначений).

Указание параметров с использованием типа DbParameter

Перед построением параметризированного запроса вы должны ознакомиться с типом

DbParameter
(который является базовым классом для объекта параметра поставщика). Класс
DbParameter
поддерживает несколько свойств, которые позволяют конфигурировать имя, размер и тип параметра, а также другие характеристики, включая направление движения параметра. Некоторые основные свойства типа
DbParameter
описаны в табл. 21.6.



Давайте теперь посмотрим, как заполнять коллекцию совместимых с

DBParameter
объектов, содержащуюся в объекте команды, для чего переделаем методы
InventoryDal
для использования параметров.

Обновление метода GetCar()

В исходной реализации метода

GetCar()
при построении строки SQL для извлечения данных об автомобиле применяется интерполяция строк С#. Чтобы обновить метод
GetCar()
, создайте экземпляр
SqlParameter
с соответствующими значениями:


SqlParameter param = new SqlParameter

{

  ParameterName = "@carId",

  Value = id,

  SqlDbType = SqlDbType.Int,

  Direction = ParameterDirection.Input

}


Значение

ParameterName
должно совпадать с именем, используемым в запросе SQL (который будет модифицирован следующим), тип обязан соответствовать типу столбца базы данных, а направление зависит от того, применяется параметр для отправки данных в запрос (
ParameterDirection.Input
) или он предназначен для возвращения данных из запроса (
ParameterDirection.Output
). Параметры также могут определяться как
InputOutput
или
ReturnValue
(возвращаемое значение, например, из хранимой процедуры).

Модифицируйте строку SQL для использования имени параметра (

"@carid"
) вместо интерполированной строки C# (
"{id}"
):


string sql =

  @"SELECT i.Id, i.Color, i.PetName,m.Name as Make

     FROM Inventory i

     INNER JOIN Makes m on m.Id = i.MakeId

     WHERE i.Id = @CarId";


Последнее обновление связано с добавлением нового объекта параметра в коллекцию

Parameters
объекта команды:


command.Parameters.Add(param);

Обновление метода DeleteCar()

Аналогично в исходной реализации метода

DeleteCar()
применяется интерполяция строк С#. Чтобы модифицировать этот метод, создайте экземпляр
SqlParameter
с надлежащими значениями:


SqlParameter param = new SqlParameter

{

  ParameterName = "@carId",

  Value = id,

  SqlDbType = SqlDbType.Int,

  Direction = ParameterDirection.Input

};


Обновите строку SQL для использования имени параметра

("@ carId"
):


string sql = "Delete from Inventory where Id = @carId";


В заключение добавьте новый объект параметра в коллекцию

Parameters
объекта команды:


command.Parameters.Add(param);

Обновление метода UpdateCarPetName()

Метод

UpdateCarPetName()
требует предоставления двух параметров: одного для
Id
автомобиля и еще одного для нового значения
PetName
. Первый параметр создается в точности как в предыдущих двух примерах (за исключением отличающегося имени переменной), а второй параметр обеспечивает отображение на тип
NVarChar
базы данных (тип поля
PetName
из таблицы
Inventory
). Обратите внимание на установку значения
Size
. Важно, чтобы этот размер совпадал с размером поля базы данных, что обеспечит отсутствие проблем при выполнении команды:


SqlParameter paramId = new SqlParameter

{

  ParameterName = "@carId",

  Value = id,

  SqlDbType = SqlDbType.Int,

  Direction = ParameterDirection.Input

};


SqlParameter paramName = new SqlParameter

{

  ParameterName = "@petName",

  Value = newPetName,

  SqlDbType = SqlDbType.NVarChar,

  Size = 50,

  Direction = ParameterDirection.Input

};


Модифицируйте строку SQL для применения параметров:


string sql = $"Update Inventory Set PetName = @petName Where Id = @carId";


Последнее обновление касается добавления новых параметров в коллекцию

Parameters
объекта команды:


command.Parameters.Add(paramId);

command.Parameters.Add(paramName);

Обновление метода InsertAuto()

Добавьте следующую версию метода

InsertAuto()
, чтобы задействовать объекты параметров:


public void InsertAuto(Car car)

{

  OpenConnection();

  // Обратите внимание на "заполнители" в запросе SQL.

  string sql = "Insert Into Inventory" +

   "(MakeId, Color, PetName) Values" +

   "(@MakeId, @Color, @PetName)";


  // Эта команда будет иметь внутренние параметры.

  using (SqlCommand command = new SqlCommand(sql, _sqlConnection))

  {

   // Заполнить коллекцию параметров.

   SqlParameter parameter = new SqlParameter

   {

    ParameterName = "@MakeId",

    Value = car.MakeId,

    SqlDbType = SqlDbType.Int,

    Direction = ParameterDirection.Input

   };

   command.Parameters.Add(parameter);


   parameter = new SqlParameter

   {

    ParameterName = "@Color",

    Value = car.Color,

    SqlDbType = SqlDbType. NVarChar,

    Size = 50,

    Direction = ParameterDirection.Input

   };

   command.Parameters.Add(parameter);


   parameter = new SqlParameter

   {

    ParameterName = "@PetName",

    Value = car.PetName,

    SqlDbType = SqlDbType. NVarChar,

    Size = 50,

    Direction = ParameterDirection.Input

   };

   command.Parameters.Add(parameter);

   command.ExecuteNonQuery();

   CloseConnection();

  }

}


В то время как построение параметризированного запроса часто требует большего объема кода, в результате получается более удобный способ для программной настройки операторов SQL и достигается лучшая производительность. Параметризированные запросы также чрезвычайно удобны, когда нужно запускать хранимые процедуры.

Выполнение хранимой процедуры

Вспомните, что хранимая процедура представляет собой именованный блок кода SQL, сохраненный в базе данных. Хранимые процедуры можно конструировать так, чтобы они возвращали набор строк либо скалярных типов данных или выполняли еще какие-то осмысленные действия (например, вставку, обновление или удаление записей); в них также можно предусмотреть любое количество необязательных параметров. Конечным результатом будет единица работы, которая ведет себя подобно типичной функции, но только находится в хранилище данных, а не в двоичном бизнес-объекте. В текущий момент в базе данных

AutoLot
определена единственная хранимая процедура по имени
GetPetName
.

Рассмотрим следующий (пока что) финальный метод типа

InventoryDal
, в котором вызывается хранимая процедура
GetPetName
:


public string LookUpPetName(int carId)

{

  OpenConnection();

  string carPetName;

  // Установить имя хранимой процедуры.

  using (SqlCommand command = new SqlCommand("GetPetName", _sqlConnection))

  {

   command.CommandType = CommandType.StoredProcedure;

   // Входной параметр.

   SqlParameter param = new SqlParameter

   {

    ParameterName = "@carId",

    SqlDbType = SqlDbType.Int,

    Value = carId,

    Direction = ParameterDirection.Input

   };

   command.Parameters.Add(param);

   // Выходной параметр.

   param = new SqlParameter

   {

    ParameterName = "@petName",

    SqlDbType = SqlDbType.NVarChar,

    Size = 50,

    Direction = ParameterDirection.Output

   };

   command.Parameters.Add(param);

   // Выполнить хранимую процедуру.

   command.ExecuteNonQuery();

   // Возвратить выходной параметр.

   carPetName = (string)command.Parameters["@petName"].Value;

   CloseConnection();

  }

  return carPetName;

}


С вызовом хранимых процедур связан один важный аспект: объект команды может представлять оператор SQL (по умолчанию) либо имя хранимой процедуры. Когда объекту команды необходимо сообщить о том, что он будет вызывать хранимую процедуру, потребуется указать имя этой процедуры (в аргументе конструктора или в свойстве

CommandText
) и установить свойство
CommandType
в
CommandType.StoredProcedure
. (В противном случае возникнет исключение времени выполнения, т.к. по умолчанию объект команды ожидает оператор SQL.)

Далее обратите внимание, что свойство

Direction
параметра
@petName
установлено в
ParameterDirection.Output
. Как и ранее, все объекты параметров добавляются в коллекцию параметров объекта команды.

После того, как хранимая процедура, запущенная вызовом метода

ExecuteNonQuery()
, завершила работу, можно получить значение выходного параметра, просмотрев коллекцию параметров объекта команды и применив соответствующее приведение:


// Возвратить выходной параметр.

carPetName = (string)command.Parameters["@petName"].Value;


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

Создание консольного клиентского приложения

Добавьте к решению

AutoLot.Dal
новый проект консольного приложения (по имени
AutoLot.Client
) и ссылку на проект
AutoLot.Dal
. Ниже приведены соответствующие CLI-команды
dotnet
(предполагается, что ваше решение называется
Chapter21_А11Projects.sin
):


dotnet new console -lang c# -n AutoLot.Client -o .\AutoLot.Client -f net5.0

dotnet sln .\Chapter21_AllProjects.sln add .\AutoLot.Client

dotnet add AutoLot.Client package Microsoft.Data.SqlClient

dotnet add AutoLot.Client reference AutoLot.Dal


В случае использования Visual Studio щелкните правой кнопкой мыши на имени решения и выберите в контекстном меню пункт AddNew Project (Добавить►Новый проект). Установите новый проект в качестве стартового (щелкнув правой кнопкой мыши на имени проекта в окне Solution Explorer и выбрав в контекстном меню пункт Set as Startup Project (Установить как стартовый проект)). Это обеспечит запуск нового проекта при инициировании отладки в Visual Studio. Если вы применяете Visual Studio Code, тогда перейдите в каталог

AutoLot.Test
и запустите проект (когда наступит время) с использованием
dotnet run
.

Очистите код, сгенерированный в

Program.cs
, и поместите в начало файла
Program.cs
следующие операторы
using
:


using System;

using System.Linq;

using AutoLot.Dal;

using AutoLot.Dal.Models;

using AutoLot.Dal.DataOperations;

using System.Collections.Generic;


Чтобы задействовать

AutoLot.Dal
, замените код метода
Main()
показанным далее кодом:


InventoryDal dal = new InventoryDal();

List list = dal.GetAllInventory();

Console.WriteLine(" ************** All Cars ************** ");

Console.WriteLine("Id\tMake\tColor\tPet Name");

foreach (var itm in list)

{

  Console.WriteLine($"{itm.Id}\t{itm.Make}\t{itm.Color}\t{itm.PetName}");

}

Console.WriteLine();

CarViewModel car =

 dal.GetCar(list.OrderBy(x=>x.Color).Select(x => x.Id).First());

Console.WriteLine(" ************** First Car By Color ************** ");

Console.WriteLine("CarId\tMake\tColor\tPet Name");

Console.WriteLine($"{car.Id}\t{car.Make}\t{car.Color}\t{car.PetName}");


try

{

  // Это потерпит неудачу из-за наличия связанных данных в таблице Orders.

  dal.DeleteCar(5);

  Console.WriteLine("Car deleted."); // Запись об автомобиле удалена.

}

catch (Exception ex)

{

  Console.WriteLine($"An exception occurred: {ex.Message}");

          // Сгенерировано исключение

}

dal.InsertAuto(new Car { Color = "Blue", MakeId = 5, PetName = "TowMonster" });

list = dal.GetAllInventory();

var newCar = list.First(x => x.PetName == "TowMonster");

Console.WriteLine(" ************** New Car ************** ");

Console.WriteLine("CarId\tMake\tColor\tPet Name");

Console.WriteLine($"{newCar.Id}\t{newCar.Make}\t{newCar.Color}\t{newCar.PetName}");

dal.DeleteCar(newCar.Id);

var petName = dal.LookUpPetName(car.Id);

Console.WriteLine(" ************** New Car ************** ");

Console.WriteLine($"Car pet name: {petName}");

Console.Write("Press enter to continue...");

Console.ReadLine();

Понятие транзакций базы данных

Давайте завершим исследование ADO.NET рассмотрением концепции транзакций базы данных. Выражаясь просто, транзакция — это набор операций базы данных, которые успешно выполняются или терпят неудачу как единая группа. Если одна из операций отказывает, тогда осуществляется откат всех остальных операций, как будто ничего не происходило. Несложно предположить, что транзакции по-настоящему важны для обеспечения безопасности, достоверности и согласованности табличных данных.

Транзакции также важны в ситуациях, когда операция базы данных включает в себя взаимодействие с множеством таблиц или хранимых процедур (либо с комбинацией атомарных элементов базы данных). Классическим примером транзакции может служить процесс перевода денежных средств с одного банковского счета на другой. Например, если вам понадобилось перевести $500 с депозитного счета на текущий чековый счет, то следующие шаги должны быть выполнены в транзакционной манере.

1. Банк должен снять $500 с вашего депозитного счета.

2. Банк должен добавить $500 на ваш текущий чековый счет.


Вряд ли бы вам понравилось, если бы деньги были сняты с депозитного счета, но не переведены (из-за какой-то ошибки со стороны банка) на текущий чековый счет, потому что вы попросту лишились бы $500. Однако если поместить указанные шаги внутрь транзакции базы данных, тогда СУБД гарантирует, что все взаимосвязанные шаги будут выполнены как единое целое. Если любая часть транзакции откажет, то будет произведен откат всей операции в исходное состояние. С другой стороны, если все шаги выполняются успешно, то транзакция будет зафиксирована.


На заметку! Из литературы, посвященной транзакциям, вам может быть известно сокращение АСЮ. Оно обозначает четыре ключевых характеристики транзакций: атомарность (atomic; все или ничего), согласованность (consistent; данные остаются устойчивыми на протяжении транзакции), изоляция (isolated; транзакции не влияют друг на друга) и постоянство (durable; транзакции сохраняются и протоколируются в журнале).


В свою очередь платформа .NET Core поддерживает транзакции различными способами. Здесь мы рассмотрим объект транзакции поставщика данных ADO.NET (

SqlTransaction
в случае
Microsoft.Data.SqlClient
).

В дополнение к готовой поддержке транзакций внутри библиотек базовых классов .NET Core можно также использовать язык SQL имеющейся СУБД. Например, вы могли бы написать хранимую процедуру, в которой применяются операторы

BEGIN TRANSACTION
,
ROLLBACK
и
COMMIT
.

Основные члены объекта транзакции ADO.NET

Все транзакции, которые будут использоваться, реализуют интерфейс

IDbTransaction
. Как упоминалось в начале главы, интерфейс
IDbTransaction
определяет несколько членов:


public interface IDbTransaction : IDisposable

{

  IDbConnection Connection { get; }

  IsolationLevel IsolationLevel { get; }

  void Commit();

  void Rollback();

}


Обратите внимание на свойство

Connection
, возвращающее ссылку на объект подключения, который инициировал текущую транзакцию (как вы вскоре увидите, объект транзакции получается из заданного объекта подключения). Метод
Commit()
вызывается, если все операции в базе данных завершились успешно, что приводит к сохранению в хранилище данных всех ожидающих изменений. И наоборот, метод
Rollback()
можно вызвать в случае генерации исключения времени выполнения, что информирует СУБД о необходимости проигнорировать все ожидающие изменения и оставить первоначальные данные незатронутыми.


На заметку! Свойство

IsolationLevel
объекта транзакции позволяет указать, насколько активно транзакция должна защищаться от действий со стороны других параллельно выполняющихся транзакций. По умолчанию транзакции полностью изолируются вплоть до их фиксации.


Помимо членов, определенных в интерфейсе

IDbTransaction
, тип
SqlTransaction
определяет дополнительный член под названием
Save()
, который предназначен для определения точек сохранения. Такая концепция позволяет откатить отказавшую транзакцию до именованной точки вместо того, чтобы осуществлять откат всей транзакции. При вызове метода
Save()
с использованием объекта
SqlTransaction
можно задавать удобный строковый псевдоним, а при вызове
Rollback()
этот псевдоним можно указывать в качестве аргумента для выполнения частичного отката. Вызов
Rollback()
без аргументов приводит к отмене всех ожидающих изменений.

Добавление метода транзакции в inventoryDal

Давайте посмотрим, как работать с транзакциями ADO.NET программным образом. Начните с открытия созданного ранее проекта библиотеки кода

AutoLot.Dal
и добавьте в класс
InventoryDal
новый открытый метод по имени
ProcessCreditRisk()
, предназначенный для работы с кредитными рисками. Метод будет искать клиента, в случае нахождения поместит его в таблицу
CreditRisks
и добавит к фамилии метку "(Credit Risk)".


public void ProcessCreditRisk(bool throwEx, int customerId)

{

  OpenConnection();

  // Найти имя текущего клиента по идентификатору.

  string fName;

  string lName;

  var cmdSelect = new SqlCommand(

   "Select * from Customers where Id = @customerId",

   _sqlConnection);

  SqlParameter paramId = new SqlParameter

  {

   ParameterName = "@customerId",

   SqlDbType = SqlDbType.Int,

   Value = customerId,

   Direction = ParameterDirection.Input

  };

  cmdSelect.Parameters.Add(paramId);

  using (var dataReader = cmdSelect.ExecuteReader())

  {

   if (dataReader.HasRows)

   {

    dataReader.Read();

    fName = (string) dataReader["FirstName"];

    lName = (string) dataReader["LastName"];

   }

   else

   {

    CloseConnection();

    return;

   }

  }

  cmdSelect.Parameters.Clear();

  // Создать объекты команды, представляющие каждый шаг операции.

  var cmdUpdate = new SqlCommand(

   "Update Customers set LastName = LastName + ' (CreditRisk) '

   where Id = @customerId", 
_sqlConnection);

  cmdUpdate.Parameters.Add(paramId);

  var cmdInsert = new SqlCommand(

   "Insert Into CreditRisks (CustomerId,FirstName, LastName)

    Values( @CustomerId, @
FirstName, @LastName)", 
_sqlConnection);

  SqlParameter parameterId2 = new SqlParameter

  {

   ParameterName = "@CustomerId",

   SqlDbType = SqlDbType.Int,

   Value = customerId,

   Direction = ParameterDirection.Input

  };

  SqlParameter parameterFirstName = new SqlParameter

  {

   ParameterName = "@FirstName",

   Value = fName,

   SqlDbType = SqlDbType.NVarChar,

   Size = 50,

   Direction = ParameterDirection.Input

  };

  SqlParameter parameterLastName = new SqlParameter

  {

   ParameterName = "@LastName",

   Value = lName,

   SqlDbType = SqlDbType.NVarChar,

   Size = 50,

   Direction = ParameterDirection.Input

  };

  cmdInsert.Parameters.Add(parameterId2);

  cmdInsert.Parameters.Add(parameterFirstName);

  cmdInsert.Parameters.Add(parameterLastName);

  // Это будет получено из объекта подключения.

  SqlTransaction tx = null;

  try

  {

   tx = _sqlConnection.BeginTransaction();

   // Включить команды в транзакцию.

   cmdInsert.Transaction = tx;

   cmdUpdate.Transaction = tx;

   // Выполнить команды.

   cmdInsert.ExecuteNonQuery();

   cmdUpdate.ExecuteNonQuery();

   // Эмулировать ошибку.

   if (throwEx)

   {

    throw new Exception("Sorry!  Database error! Tx failed...");

   // Возникла ошибка, связанная с базой данных! Отказ транзакции...

   }

   // Зафиксировать транзакцию!

   tx.Commit();

  }

  catch (Exception ex)

  {

   Console.WriteLine(ex.Message);

   // Любая ошибка приведет к откату транзакции.

   // Использовать условную операцию для проверки на предмет null.

   tx?.Rollback();

  }

  finally

  {

   CloseConnection();

  }

}


Здесь используется входной параметр типа

bool
, который указывает, нужно ли генерировать произвольное исключение при попытке обработки проблемного клиента. Такой прием позволяет эмулировать непредвиденные обстоятельства, которые могут привести к неудачному завершению транзакции. Понятно, что это делается лишь в демонстрационных целях; настоящий метод транзакции не должен позволять вызывающему процессу нарушать работу логики по своему усмотрению!

Обратите внимание на применение двух объектов

SqlCommand
для представления каждого шага транзакции, которая будет запущена. После получения имени и фамилии клиента на основе входного параметра
customerID
с помощью метода
BeginTransaction()
объекта подключения можно получить допустимый объект
SqlTransaction
. Затем (что очень важно) потребуется привлечь к участию каждый объект команды, присвоив его свойству
Transaction
полученного объекта транзакции. Если этого не сделать, то логика вставки и обновления не будет находиться в транзакционном контексте.

После вызова метода

ExecuteNonQuery()
на каждой команде генерируется исключение, если (и только если) значение параметра
bool
равно
true
. В таком случае происходит откат всех ожидающих операций базы данных. Если исключение не было сгенерировано, тогда в результате вызова
Commit()
оба шага будут зафиксированы в таблицах базы данных.

Тестирование транзакции базы данных

Выберите одного из клиентов, добавленных в таблицу

Customers
(например,
Dave Benner
,
Id = 1
). Добавьте в
Program.cs
внутри проекта
AutoLot.Client
новый метод по имени
FlagCustomer()
:


void FlagCustomer()

{

  Console.WriteLine("***** Simple Transaction Example *****\n");

  // Простой способ позволить транзакции успешно завершиться или отказать.

  bool throwEx = true;

  Console.Write("Do you want to throw an exception (Y or N): ");

        // Хотите ли вы сгенерировать исключение?

  var userAnswer = Console.ReadLine();

  if (string.IsNullOrEmpty(userAnswer) ||

    userAnswer.Equals("N",StringComparison.
OrdinalIgnoreCase))

  {

   throwEx = false;

  }

  var dal = new InventoryDal();

  // Обработать клиента 1 - ввести идентификатор клиента,

  // подлежащего перемещению.

  dal.ProcessCreditRisk(throwEx, 1);

  Console.WriteLine("Check CreditRisk table for results");

          // Результаты ищите в таблице CreditRisk

  Console.ReadLine();

}


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

Customers
не изменилась, т.к. произошел откат всей транзакции. Однако если исключение не генерировалось, тогда окажется, что фамилия клиента в таблице
Customers
изменилась и была добавлена в таблицу
CreditRisks
.

Выполнение массового копирования с помощью ADO.NET

В случае, когда необходимо загрузить много записей в базу данных, показанные до сих пор методы будут довольно неэффективными. В SQL Server имеется средство, называемое массовым копированием, которое предназначено специально для таких сценариев, и в ADO.NET для него предусмотрена оболочка в виде класса

SqlBulkCopy
. В настоящем разделе главы объясняется, как выполнять массовое копирование с помощью ADO.NET.

Исследование класса SqlBulkCopy

Класс

SqlBulkCopy
имеет один метод,
WriteToServer()
(и его асинхронную версию
WriteToServerAsync()
), который обрабатывает список записей и помещает данные в базу более эффективно, чем последовательность операторов
Insert
, выполненная с помощью объектов команд. Метод
WriteToServer()
перегружен, чтобы принимать объект
DataTable
, объект
DataReader
или массив объектов
DataRow
. Придерживаясь тематики главы, мы собираемся использовать версию
WriteToServer()
, которая принимает
DataReader
, так что необходимо создать специальный класс чтения данных.

Создание специального класса чтения данных

Желательно, чтобы специальный класс чтения данных был обобщенным и содержал список моделей, которые нужно импортировать. Создайте в проекте

AutoLot.DAL
новую папку по имени
BulkImport
, a в ней — новый файл интерфейса
IMyDataReader.cs
, реализующего
IDataReader
, со следующим кодом:


using System.Collections.Generic;

using System.Data;


namespace AutoLot.Dal.BulkImport

{

  public interface IMyDataReader : IDataReader

  {

   List Records { get; set; }

  }

}


Далее реализуйте специальный класс чтения данных. Как вы уже видели, классы чтения данных содержат много частей, отвечающих за перемещение данных. Хорошая новость в том, что для

SqlBulkCopy
придется реализовать лишь несколько из них. Создайте новый файл класса по имени
MyDataReader.cs
и добавьте в него перечисленные ниже операторы
using
:


using System;

using System.Collections.Generic;

using System.Data;

using System.Linq;

using System.Reflection;


Сделайте класс открытым и запечатанным и обеспечьте реализацию классом интерфейса

IMyDataReader
. Добавьте конструктор для принятия записей и установки свойства:


public sealed class MyDataReader : IMyDataReader

{

  public List Records { get; set; }

  public MyDataReader(List records)

  {

   Records = records;

  }

}


Предложите Visual Studio или Visual Studio Code самостоятельно реализовать все методы (либо скопировать их), что даст вам отправную точку для специального класса чтения данных. В рассматриваемом сценарии потребуется реализовать лишь члены, кратко описанные в табл. 21.7.



Начните с метода

Read()
, который возвращает
false
, если класс для чтения находится в конце списка, или
true
(с инкрементированием счетчика уровня класса), если конец списка еще не достигнут. Добавьте переменную уровня класса, которая будет хранить текущий индекс
List
, и обновите метод
Read()
, как показано ниже:


public class MyDataReader : IMyDataReader

{

  ...

  private int _currentIndex = -1;

  public bool Read()

  {

   if (_currentIndex + 1 >= Records.Count)

   {

    return false;

   }

   _currentIndex++;

   return true;

  }

}


Каждый метод

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


public object GetValue(int i)

{

  Car currentRecord = Records[_currentIndex] as Car;

  return i switch

  {

   0 => currentRecord.Id,

   1 => currentRecord.MakeId,

   2 => currentRecord.Color,

   3 => currentRecord.PetName,

   4 => currentRecord.TimeStamp,

   _ => string.Empty,

  };

}


База данных содержит только четыре таблицы, но это означает необходимость в наличии четырех вариаций класса чтения данных. А подумайте о реальной производственной базе данных, в которой таблиц гораздо больше!Решить проблему можно более эффективно с применением рефлексии (см. главу 17) и LINQ to Objects (см. главу 13).

Добавьте переменные

readonly
для хранения значений
PropertyInfo
модели и словарь, который будет использоваться для хранения местоположения поля и имени таблицы в SQL Server. Модифицируйте конструктор, чтобы он принимал свойства обобщенного типа и инициализировал объект
Dictionary
. Ниже показан добавленный код:


private readonly PropertyInfo[] _propertyInfos;

private readonly Dictionary _nameDictionary;


public MyDataReader(List records)

{

  Records = records;

  _propertyInfos = typeof(T).GetProperties();

  _nameDictionary = new Dictionary();

}


Модифицируйте конструктор, чтобы он принимал строку подключения

SQLConnection
, а также строки для имен схемы и таблицы, куда будут вставлены записи, и добавьте для этих значений переменные уровня класса:


private readonly SqlConnection _connection;

private readonly string _schema;

private readonly string _tableName;


public MyDataReader(List records, SqlConnection connection,

          string schema, string 
tableName)

{

  Records = records;

  _propertyInfos = typeof(T).GetProperties();

  _nameDictionary = new Dictionary();


  _connection = connection;

  _schema = schema;

  _tableName = tableName;

}


Далее реализуйте метод

GetSchemaTable()
, который извлекает информацию SQL Server, касающуюся целевой таблицы:


public DataTable GetSchemaTable()

{

  using var schemaCommand =

   new SqlCommand($"SELECT * FROM {_schema}.{_tableName}", _
connection);

  using var reader = schemaCommand.ExecuteReader(CommandBehavior.SchemaOnly);

  return reader.GetSchemaTable();

}


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

SchemaTable
для создания словаря, который содержит поля целевой таблицы в порядке их следования внутри базы данных:


public MyDataReader(List records, SqlConnection connection,

           string schema, string tableName)

{

  ...

  DataTable schemaTable = GetSchemaTable();

  for (int x = 0; x

  {

   DataRow col = schemaTable.Rows[x];

   var columnName = col.Field("ColumnName");

   _nameDictionary.Add(x,columnName);

  }

}


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


public int FieldCount => _propertyInfos.Length;

public object GetValue(int i)

  => _propertyInfos

    .First(x=>x.Name.Equals(_nameDictionary[i],

                StringComparison.OrdinalIgnoreCase))

    .GetValue(Records[_currentIndex]);


Для справки ниже приведены остальные методы, которые должны присутствовать (но не реализованы):


public string GetName(int i) => throw new NotImplementedException();

public int GetOrdinal(string name) => throw new NotImplementedException();

public string GetDataTypeName(int i) => throw new NotImplementedException();

public Type GetFieldType(int i) => throw new NotImplementedException();

public int GetValues(object[] values) => throw new NotImplementedException();

public bool GetBoolean(int i) => throw new NotImplementedException();

public byte GetByte(int i) => throw new NotImplementedException();

public long GetBytes(int i, long fieldOffset, byte[] buffer,

  int bufferoffset, int length)

  => throw new NotImplementedException();

public char GetChar(int i) => throw new NotImplementedException();

public long GetChars(int i, long fieldoffset, char[] buffer,

   int bufferoffset, int length)

  => throw new NotImplementedException();

public Guid GetGuid(int i) => throw new NotImplementedException();

public short GetInt16(int i) => throw new NotImplementedException();

public int GetInt32(int i) => throw new NotImplementedException();

public long GetInt64(int i) => throw new NotImplementedException();

public float GetFloat(int i) => throw new NotImplementedException();

public double GetDouble(int i)  => throw new NotImplementedException();

public string GetString(int i) => throw new NotImplementedException();

public decimal GetDecimal(int i) => throw new NotImplementedException();

public DateTime GetDateTime(int i) => throw new NotImplementedException();

public IDataReader GetData(int i) => throw new NotImplementedException();

public bool IsDBNull(int i) => throw new NotImplementedException();

object IDataRecord.this[int i] => throw new NotImplementedException();

object IDataRecord.this[string name] => throw new NotImplementedException();

public void Close() => throw new NotImplementedException();

public DataTable GetSchemaTable() => throw new NotImplementedException();

public bool NextResult() => throw new NotImplementedException();

public int Depth { get; }

public bool IsClosed { get; }

public int RecordsAffected { get; }

Выполнение массового копирования

Добавьте в папку

BulkImport
новый файл открытого статического класса по имени
ProcessBulkImport.cs
. Поместите в начало файла следующие операторы
using
:


using System;

using System.Collections.Generic;

using System.Data;

using System.Linq;

using Microsoft.Data.SqlClient;


Добавьте код для поддержки открытия и закрытия подключений (похожий на код в классе

InventoryDal
):


private const string ConnectionString =

  @"Data Source=.,5433;User Id=sa;Password=P@ssw0rd;Initial Catalog=AutoLot";

private static SqlConnection _sqlConnection = null;


private static void OpenConnection()

{

  _sqlConnection = new SqlConnection

  {

   ConnectionString = ConnectionString

  };

  _sqlConnection.Open();

}


private static void CloseConnection()

{

  if (_sqlConnection?.State != ConnectionState.Closed)

  {

   _sqlConnection?.Close();

  }

}


Для обработки записей классу

SqlBulkCopy
требуется имя (и схема, если она отличается от
dbo
). После создания нового экземпляра
SqlBulkCopy
(с передачей объекта подключения) установите свойство
DestinationTableName
. Затем создайте новый экземпляр специального класса чтения данных, который хранит список объектов, подлежащих массовому копированию, и вызовите метод
WriteToServer()
. Ниже приведен код метода
ExecuteBulklmport()
:


public static void ExecuteBulkImport(IEnumerable records,

                     string tableName)

{

  OpenConnection();

  using SqlConnection conn = _sqlConnection;

  SqlBulkCopy bc = new SqlBulkCopy(conn)

  {

   DestinationTableName = tableName

  };

  var dataReader = new MyDataReader(records.ToList(),_sqlConnection, 

                     "dbo",tableName);

  try

  {

   bc.WriteToServer(dataReader);

  }

  catch (Exception ex)

  {

   // Здесь должно что-то делаться.

  }

  finally

  {

   CloseConnection();

  }

}

Тестирование массового копирования

Возвратите в проект

AutoLot.Client
и добавьте в
Program.cs
следующие операторы
using
:


using AutoLot.Dal.BulkImport;

using SystemCollections.Generic;


Добавьте в файл

Program.cs
новый метод по имени
DoBulkCopy()
. Создайте список объектов
Car
и передайте его вместе с именем таблицы методу
ExecuteBulklmport()
. Оставшаяся часть кода отображает результаты массового копирования.


void DoBulkCopy()

{

  Console.WriteLine(" ************** Do Bulk Copy ************** ");

  var cars = new List

  {

   new Car() {Color = "Blue", MakeId = 1, PetName = "MyCar1"},

   new Car() {Color = "Red", MakeId = 2, PetName = "MyCar2"},

   new Car() {Color = "White", MakeId = 3, PetName = "MyCar3"},

   new Car() {Color = "Yellow", MakeId = 4, PetName = "MyCar4"}

  };

  ProcessBulkImport.ExecuteBulkImport(cars, "Inventory");

  InventoryDal dal = new InventoryDal();

  List list = dal.GetAllInventory();

  Console.WriteLine(" ************** All Cars ************** ");

  Console.WriteLine("CarId\tMake\tColor\tPet Name");

  foreach (var itm in list)

  {

   Console.WriteLine(

    $"{itm.Id}\t{itm.Make}\t{itm.Color}\t{itm.PetName}");

  }

  Console.WriteLine();

}


Хотя добавление четырех новых объектов

Car
не показывает достоинства работы, связанной с применением класса
SqlBulkCopy
, вообразите себе попытку загрузки тысяч записей. Мы проделывали подобное с таблицей
Customers
, и время загрузки составляло считанные секунды, тогда как проход в цикле по всем записям занимал часы! Как и все в .NET Core, класс
SqlBulkCopy
— просто еще один инструмент, который должен находиться в вашем инструментальном наборе и использоваться в ситуациях, когда в этом есть смысл.

Резюме

Инфраструктура ADO.NET представляет собой собственную технологию доступа к данным платформы .NET Core. В настоящей главе было начато исследование роли поставщиков данных, которые по существу являются конкретными реализациями нескольких абстрактных базовых классов (из пространства имен

System.Data.Common
) и интерфейсных типов (из пространства имен
System.Data
). Вы видели, что с применением модели фабрики поставщиков данных ADO.NET можно построить кодовую базу, не зависящую от поставщика.

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

Наконец, вы научились защищать код манипулирования данными с помощью транзакций и ознакомились с применением класса

SqlBulkCopy
для загрузки крупных объемов данных в базы данных SQL Server, используя ADO.NET.

Загрузка...