C#9 和 .NET5 高级教程(十一)
二十、文件 I/O 和对象序列化
当您创建桌面应用时,在用户会话之间保存信息的能力是很常见的。本章从的角度研究了几个与 I/O 相关的主题。NET 核心框架。首要任务是探索在System.IO名称空间中定义的核心类型,并学习如何以编程方式修改机器的目录和文件结构。下一个任务是探索读取和写入基于字符、基于二进制、基于字符串和基于内存的数据存储的各种方法。
在您学习了如何使用核心 I/O 类型操作文件和目录之后,您将研究相关的主题对象序列化。您可以使用对象序列化将对象的状态持久化并检索到任何从System.IO.Stream派生的类型。
Note
为了确保您可以运行本章中的每个示例,请以管理权限启动 Visual Studio(只需右击 Visual Studio 图标并选择“以管理员身份运行”)。如果不这样做,在访问计算机文件系统时可能会遇到运行时安全异常。
探索系统。IO 命名空间
在...的框架内。NET 核心中,System.IO命名空间是专用于基于文件(和基于内存)的输入和输出(I/O)服务的基类库区域。像任何名称空间一样,System.IO定义了一组类、接口、枚举、结构和委托,其中大部分可以在mscorlib.dll中找到。除了包含在mscorlib.dll中的类型之外,System.dll程序集还定义了System.IO名称空间的其他成员。
System.IO名称空间中的许多类型侧重于物理目录和文件的编程操作。但是,其他类型支持从字符串缓冲区以及原始内存位置读取数据和向其中写入数据。表 20-1 概述了核心(非抽象)类,提供了System.IO中功能的路线图。
表 20-1。
System.IO名称空间的主要成员
非抽象 I/O 类类型
|
生命的意义
|
| --- | --- |
| BinaryReader``BinaryWriter | 这些类允许您以二进制值的形式存储和检索原始数据类型(整数、布尔值、字符串等等)。 |
| BufferedStream | 此类为字节流提供临时存储,您可以在以后提交给存储。 |
| Directory``DirectoryInfo | 您可以使用这些类来操作机器的目录结构。Directory类型使用静态成员公开功能,而DirectoryInfo类型从有效的对象引用公开类似的功能。 |
| DriveInfo | 该类提供关于给定机器使用的驱动器的详细信息。 |
| File``FileInfo | 您使用这些类来操作机器的一组文件。File类型使用静态成员公开功能,而FileInfo类型从有效的对象引用公开类似的功能。 |
| FileStream | 这个类为您提供了随机文件访问(例如,搜索功能),数据以字节流的形式表示。 |
| FileSystemWatcher | 这个类允许你监视指定目录中外部文件的修改。 |
| MemoryStream | 该类提供对存储在内存中而不是物理文件中的流数据的随机访问。 |
| Path | 这个类以平台无关的方式对包含文件或目录路径信息的System.String类型执行操作。 |
| StreamWriter``StreamReader | 您可以使用这些类将文本信息存储到(或从)文件中检索。这些类型不支持随机文件访问。 |
| StringWriter``StringReader | 像StreamReader / StreamWriter类一样,这些类也处理文本信息。然而,底层存储是一个字符串缓冲区,而不是一个物理文件。 |
除了这些具体的类类型,System.IO还定义了几个枚举,以及一组抽象类(例如,Stream、TextReader和TextWriter,它们为所有后代定义了一个共享的多态接口。在这一章中,你将会读到许多这种类型的内容。
目录(信息)和文件(信息)类型
提供四个类,允许你操作单个文件,以及与机器的目录结构交互。前两种类型,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 列出了一些感兴趣的核心属性。
表 20-2。
FileSystemInfo属性
财产
|
生命的意义
|
| --- | --- |
| Attributes | 获取或设置与当前文件关联的属性,这些属性由FileAttributes枚举表示(例如,文件或目录是只读的、加密的、隐藏的还是压缩的?) |
| CreationTime | 获取或设置当前文件或目录的创建时间 |
| Exists | 确定给定的文件或目录是否存在 |
| Extension | 检索文件的扩展名 |
| FullName | 获取目录或文件的完整路径 |
| LastAccessTime | 获取或设置上次访问当前文件或目录的时间 |
| LastWriteTime | 获取或设置上次写入当前文件或目录的时间 |
| Name | 获取当前文件或目录的名称 |
FileSystemInfo也定义了Delete()方法。这是通过派生类型从硬盘上删除给定的文件或目录来实现的。此外,您可以在获取属性信息之前调用Refresh(),以确保关于当前文件(或目录)的统计信息没有过时。
使用 DirectoryInfo 类型
您将研究的第一个可创建的以 I/O 为中心的类型是DirectoryInfo类。该类包含一组用于创建、移动、删除和枚举目录和子目录的成员。除了其基类(FileSystemInfo)提供的功能外,DirectoryInfo还提供表 20-3 中详细列出的关键成员。
表 20-3。
DirectoryInfo类型的主要成员
成员
|
生命的意义
|
| --- | --- |
| Create()``CreateSubdirectory() | 给定路径名时,创建一个目录(或一组子目录) |
| Delete() | 删除目录及其所有内容 |
| GetDirectories() | 返回代表当前目录中所有子目录的DirectoryInfo对象数组 |
| GetFiles() | 检索代表给定目录中一组文件的一组FileInfo对象 |
| MoveTo() | 将目录及其内容移动到新路径 |
| Parent | 检索了此目录的父目录 |
| Root | 获取路径的根部分 |
通过指定一个特定的目录路径作为构造函数参数,开始使用DirectoryInfo类型。如果您想要访问当前工作目录(执行应用的目录),请使用点(.)符号。以下是一些例子:
// Bind to the current working directory.
DirectoryInfo dir1 = new DirectoryInfo(".");
// Bind to C:\Windows,
// using a verbatim string.
DirectoryInfo dir2 = new DirectoryInfo(@"C:\Windows");
在第二个例子中,假设传递给构造函数(C:\Windows)的路径已经存在于物理机器上。然而,如果您试图与一个不存在的目录交互,就会抛出一个System.IO.DirectoryNotFoundException。因此,如果您指定了一个尚未创建的目录,您需要在继续之前调用Create()方法,如下所示:
// Bind to a nonexistent directory, then create it.
DirectoryInfo dir3 = new DirectoryInfo(@"C:\MyCode\Testing");
dir3.Create();
上例中使用的路径语法是以窗口为中心的。如果你在发展。NET 核心应用,您应该使用Path.VolumeSeparatorChar和Path.DirectorySeparatorChar构造,它们将基于平台产生适当的字符。将前面的代码更新为以下内容:
DirectoryInfo dir3 = new DirectoryInfo(
$@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}MyCode{Path.DirectorySeparatorChar}Testing");
在创建了一个DirectoryInfo对象之后,您可以使用从FileSystemInfo继承的任何属性来研究底层目录内容。要看到这一点,创建一个名为 DirectoryApp 的新控制台应用项目,并更新您的 C# 文件以导入System和System.IO。用以下新的静态方法更新您的Program类,该方法创建一个映射到C:\Windows的新的DirectoryInfo对象(如果需要,调整您的路径),它显示几个有趣的统计数据:
using System;
using System.IO;
Console.WriteLine("***** Fun with Directory(Info) *****\n");
ShowWindowsDirectoryInfo();
Console.ReadLine();
static void ShowWindowsDirectoryInfo()
{
// Dump directory information. If you are not on Windows, plug in another directory
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:\
**************************
枚举 DirectoryInfo 类型的文件
除了获得现有目录的基本细节,您还可以扩展当前示例,使用一些DirectoryInfo类型的方法。首先,您可以利用GetFiles()方法获得位于C:\Windows\Web\Wallpaper目录中的所有*.jpg文件的信息。
Note
如果您不是在 Windows 机器上,修改此代码以读取您机器上某个目录下的文件。记住使用Path.VolumeSeparatorChar和Path.DirectorySeparatorChar值来使你的代码跨平台兼容。
GetFiles()方法返回一个由FileInfo对象组成的数组,每个对象公开一个特定文件的细节(你将在本章后面了解到FileInfo类型的全部细节)。在Program类中创建以下静态方法:
static void DisplayImageFiles()
{
DirectoryInfo dir = new
DirectoryInfo(@"C:\Windows\Web\Wallpaper");
// Get all files with a *.jpg extension.
FileInfo[] imageFiles =
dir.GetFiles("*.jpg", SearchOption.AllDirectories);
// How many were found?
Console.WriteLine("Found {0} *.jpg files\n", imageFiles.Length);
// Now print out info for each file.
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()时,您指定了一个搜索选项;这样做是为了在根目录的所有子目录中查找。运行应用后,您将看到所有符合搜索模式的文件的列表。
创建 DirectoryInfo 类型的子目录
您可以使用DirectoryInfo.CreateSubdirectory()方法以编程方式扩展目录结构。此方法可以在一次函数调用中创建一个子目录以及多个嵌套子目录。这个方法演示了如何做到这一点,用一些自定义子目录扩展了应用执行目录(用.表示)的目录结构:
static void ModifyAppDirectory()
{
DirectoryInfo dir = new DirectoryInfo(".");
// Create \MyFolder off application directory.
dir.CreateSubdirectory("MyFolder");
// Create \MyFolder2\Data off application directory.
dir.CreateSubdirectory(
$@"MyFolder2{Path.DirectorySeparatorChar}Data");
}
您不需要捕获CreateSubdirectory()方法的返回值,但是您应该知道,表示新创建的项目的DirectoryInfo对象在成功执行时被传递回来。考虑对先前方法的以下更新:
static void ModifyAppDirectory()
{
DirectoryInfo dir = new DirectoryInfo(".");
// Create \MyFolder off initial directory.
dir.CreateSubdirectory("MyFolder");
// Capture returned DirectoryInfo object.
DirectoryInfo myDataFolder = dir.CreateSubdirectory(
$@"MyFolder2{Path.DirectorySeparatorChar}Data");
// Prints path to ..\MyFolder2\Data.
Console.WriteLine("New Folder is: {0}", myDataFolder);
}
如果从顶级语句中调用此方法,并使用 Windows 资源管理器检查 Windows 目录,您将看到新的子目录存在并被考虑在内。
使用目录类型
你已经看到了DirectoryInfo型的作用;现在你已经准备好学习Directory类型了。在很大程度上,Directory的静态成员模仿了由DirectoryInfo定义的实例级成员所提供的功能。然而,回想一下,Directory的成员通常返回字符串数据,而不是强类型的FileInfo / DirectoryInfo对象。
现在让我们看看Directory类型的一些功能。这个最后的帮助器函数显示映射到当前计算机的所有驱动器的名称(使用Directory.GetLogicalDrives()方法),并使用静态的Directory.Delete()方法删除之前创建的\MyFolder和\MyFolder2\Data子目录。
static void FunWithDirectoryType()
{
// List all drives on current computer.
string[] drives = Directory.GetLogicalDrives();
Console.WriteLine("Here are your drives:");
foreach (string s in drives)
{
Console.WriteLine("--> {0} ", s);
}
// Delete what was created.
Console.WriteLine("Press Enter to delete directories");
Console.ReadLine();
try
{
Directory.Delete("MyFolder");
// The second parameter specifies whether you
// wish to destroy any subdirectories.
Directory.Delete("MyFolder2", true);
}
catch (IOException e)
{
Console.WriteLine(e.Message);
}
}
使用 DriveInfo 类类型
System.IO名称空间提供了一个名为DriveInfo的类。像Directory.GetLogicalDrives()一样,静态DriveInfo.GetDrives()方法允许您发现机器驱动器的名称。然而,与Directory.GetLogicalDrives()不同的是,DriveInfo提供了许多其他细节(例如,驱动器类型、可用空间和卷标)。考虑在名为 DriveInfoApp 的新控制台应用项目中定义的以下Program类:
using System;
using System.IO;
// Get info regarding all drives.
DriveInfo[] myDrives = DriveInfo.GetDrives();
// Now print drive stats.
foreach(DriveInfo d in myDrives)
{
Console.WriteLine("Name: {0}", d.Name);
Console.WriteLine("Type: {0}", d.DriveType);
// Check to see whether the drive is mounted.
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、DirectoryInfo和DriveInfo类的一些核心行为。接下来,您将学习如何创建、打开、关闭和销毁填充给定目录的文件。
使用 FileInfo 类
如前面的DirectoryApp示例所示,FileInfo类允许您获取硬盘上现有文件的详细信息(例如,创建时间、大小和文件属性),并帮助创建、复制、移动和销毁文件。除了由FileSystemInfo继承的功能集,你可以找到一些FileInfo类独有的核心成员,你可以在表 20-4 中看到描述。
表 20-4。
FileInfo核心成员
成员
|
生命的意义
|
| --- | --- |
| AppendText() | 创建一个向文件追加文本的StreamWriter对象(稍后描述) |
| CopyTo() | 将现有文件复制到新文件中 |
| Create() | 创建一个新文件并返回一个FileStream对象(稍后描述)来与新创建的文件交互 |
| CreateText() | 创建一个写新文本文件的StreamWriter对象 |
| Delete() | 删除绑定了FileInfo实例的文件 |
| Directory | 获取父目录的实例 |
| DirectoryName | 获取父目录的完整路径 |
| Length | 获取当前文件的大小 |
| MoveTo() | 将指定文件移动到新位置,并提供指定新文件名的选项 |
| Name | 获取文件的名称 |
| Open() | 以各种读/写和共享权限打开文件 |
| OpenRead() | 创建一个只读的FileStream对象 |
| OpenText() | 创建一个从现有文本文件中读取的StreamReader对象(稍后描述) |
| OpenWrite() | 创建一个只写的FileStream对象 |
请注意,FileInfo类的大多数方法都返回一个特定的以 I/O 为中心的对象(例如,FileStream和StreamWriter),该对象允许您开始以各种格式向相关文件读写数据。您将很快了解这些类型;然而,在您看到一个工作示例之前,您会发现检查使用FileInfo类类型获得文件句柄的各种方法是有帮助的。
文件信息。Create()方法
下一组示例都在一个名为 SimpleFileIO 的控制台应用中。创建文件句柄的一种方法是使用FileInfo.Create()方法,如下所示:
using System;
using System.IO;
Console.WriteLine("***** Simple IO with the File Type *****\n");
//Change to a folder on your machine that you have read/write access to, or run as administrator
var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}temp{Path.DirectorySeparatorChar}Test.dat";
// Make a new file on the C drive.
FileInfo f = new FileInfo(fileName);
FileStream fs = f.Create();
// Use the FileStream object...
// Close down file stream.
fs.Close();
Note
这些示例可能需要以管理员身份运行 Visual Studio,具体取决于您的用户权限和系统配置。
注意,FileInfo.Create()方法返回了一个FileStream对象,该对象公开了对底层文件的同步和异步写/读操作(稍后会有更多细节)。注意由FileInfo.Create()返回的FileStream对象授予所有用户完全的读/写权限。
还要注意,在使用完当前的FileStream对象后,必须确保关闭句柄来释放底层的非托管流资源。鉴于FileStream实现了IDisposable,你可以使用 C# using的作用域来允许编译器生成拆卸逻辑(详见第八章),如下所示:
var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";
...
//wrap the file stream in a using statement
// Defining a using scope for file I/O
FileInfo f1 = new FileInfo(fileName);
using (FileStream fs1 = f1.Create())
{
// Use the FileStream object...
}
f1.Delete();
Note
本章中几乎所有的例子都包含了using语句。我本可以使用新的 using 声明语法,但在这次重写中没有这样做,以保持示例集中在我们正在检查的System.IO组件上。
文件信息。Open()方法
您可以使用FileInfo.Open()方法打开现有文件,也可以创建比使用FileInfo.Create()更精确的新文件。这是可行的,因为Open()通常采用几个参数来限定如何迭代您想要操作的文件。一旦对Open()的调用完成,就会返回一个FileStream对象。考虑以下逻辑:
var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";
...
// Make a new file via FileInfo.Open().
FileInfo f2 = new FileInfo(fileName);
using(FileStream fs2 = f2.Open(FileMode.OpenOrCreate,
FileAccess.ReadWrite, FileShare.None))
{
// Use the FileStream object...
}
f2.Delete();
这个版本的重载Open()方法需要三个参数。Open()方法的第一个参数指定了 I/O 请求的一般风格(例如,创建一个新文件,打开一个现有文件,并追加到一个文件中),这可以使用FileMode枚举来指定(详细信息请参见表 20-5 ,如下所示:
表 20-5。
FileMode枚举的成员
成员
|
生命的意义
|
| --- | --- |
| CreateNew | 通知操作系统创建一个新文件。如果它已经存在,抛出一个IOException。 |
| Create | 通知操作系统创建一个新文件。如果它已经存在,它将被覆盖。 |
| Open | 打开现有文件。如果文件不存在,抛出一个FileNotFoundException。 |
| OpenOrCreate | 如果文件存在,则打开该文件;否则,将创建一个新文件。 |
| Truncate | 打开一个现有文件并将文件截断为 0 字节大小。 |
| Append | 打开一个文件,移动到文件末尾,并开始写操作(只能对只写流使用此标志)。如果文件不存在,将创建一个新文件。 |
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
}
文件信息。OpenRead()和 FileInfo。OpenWrite()方法
FileInfo.Open()方法允许您以灵活的方式获得文件句柄,但是FileInfo类也提供了名为OpenRead()和OpenWrite()的成员。正如您所想象的,这些方法返回一个正确配置的只读或只写的FileStream对象,而不需要提供各种枚举值。和FileInfo.Create()和FileInfo.Open()一样,OpenRead()和OpenWrite()返回一个FileStream对象。
注意,OpenRead()方法要求文件已经存在。下面的代码创建文件,然后关闭FileStream,这样它就可以被OpenRead()方法使用了:
f3.Create().Close();
以下是完整的示例:
var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";
...
// Get a FileStream object with read-only permissions.
FileInfo f3 = new FileInfo(fileName);
//File must exist before using OpenRead
f3.Create().Close();
using(FileStream readOnlyStream = f3.OpenRead())
{
// Use the FileStream object...
}
f3.Delete();
// Now get a FileStream object with write-only permissions.
FileInfo f4 = new FileInfo(fileName);
using(FileStream writeOnlyStream = f4.OpenWrite())
{
// Use the FileStream object...
}
f4.Delete();
文件信息。OpenText()方法
FileInfo类型的另一个开中心成员是OpenText()。与Create()、Open()、OpenRead()或OpenWrite()不同,OpenText()方法返回StreamReader类型的实例,而不是FileStream类型的实例。假设您在C:驱动器上有一个名为boot.ini的文件,下面的代码片段让您可以访问它的内容:
var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";
...
// Get a StreamReader object.
//If not on a Windows machine, change the file name accordingly
FileInfo f5 = new FileInfo(fileName);
//File must exist before using OpenText
f5.Create().Close();
using(StreamReader sreader = f5.OpenText())
{
// Use the StreamReader object...
}
f5.Delete();
很快您就会看到,StreamReader类型提供了一种从底层文件读取字符数据的方法。
文件信息。CreateText()和 FileInfo。AppendText()方法
此时最后两个感兴趣的FileInfo方法是CreateText()和AppendText()。两者都返回一个StreamWriter对象,如下所示:
var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";
...
FileInfo f6 = new FileInfo(fileName);
using(StreamWriter swriter = f6.CreateText())
{
// Use the StreamWriter object...
}
f6.Delete();
FileInfo f7 = new FileInfo(fileName);
using(StreamWriter swriterAppend = f7.AppendText())
{
// Use the StreamWriter object...
}
f7.Delete();
正如您可能猜到的,StreamWriter类型提供了一种将字符数据写入底层文件的方法。
使用文件类型
File类型使用几个静态成员来提供与FileInfo类型几乎相同的功能。像FileInfo、File供给AppendText()、Create()、CreateText()、Open()、OpenRead()、OpenWrite()、OpenText()的方法。在许多情况下,您可以互换使用File和FileInfo类型。注意OpenText()和OpenRead()要求文件已经存在。要看到这一点,您可以通过使用File类型来简化前面的每个FileStream示例,如下所示:
var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";
...
//Using File instead of FileInfo
using (FileStream fs8 = File.Create(fileName))
{
// Use the FileStream object...
}
File.Delete(fileName);
// Make a new file via FileInfo.Open().
using(FileStream fs9 = File.Open(fileName,
FileMode.OpenOrCreate, FileAccess.ReadWrite,
FileShare.None))
{
// Use the FileStream object...
}
// Get a FileStream object with read-only permissions.
using(FileStream readOnlyStream = File.OpenRead(fileName))
{}
File.Delete(fileName);
// Get a FileStream object with write-only permissions.
using(FileStream writeOnlyStream = File.OpenWrite(fileName))
{}
// Get a StreamReader object.
using(StreamReader sreader = File.OpenText(fileName))
{}
File.Delete(fileName);
// Get some StreamWriters.
using(StreamWriter swriter = File.CreateText(fileName))
{}
File.Delete(fileName);
using(StreamWriter swriterAppend =
File.AppendText(fileName))
{}
File.Delete(fileName)
;
其他以文件为中心的成员
File类型还支持一些成员,如表 20-6 所示,可以大大简化读写文本数据的过程。
表 20-6。
文件类型的方法
|方法
|
生命的意义
|
| --- | --- |
| ReadAllBytes() | 打开指定的文件,以字节数组的形式返回二进制数据,然后关闭文件 |
| ReadAllLines() | 打开指定的文件,以字符串数组的形式返回字符数据,然后关闭文件 |
| ReadAllText() | 打开指定文件,返回字符数据作为System.String,然后关闭文件 |
| WriteAllBytes() | 打开指定的文件,写出字节数组,然后关闭文件 |
| WriteAllLines() | 打开指定文件,写出字符串数组,然后关闭文件 |
| WriteAllText() | 打开指定文件,写入指定字符串中的字符数据,然后关闭该文件 |
您可以使用这些File类型的方法,只用几行代码就可以读写成批的数据。更好的是,这些成员中的每一个都会自动关闭底层文件句柄。例如,下面的控制台程序(名为SimpleFileIO)将字符串数据持久化到C:驱动器上的一个新文件中(并将它读入内存中),而不会产生任何麻烦(这个例子假设您已经导入了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"};
// Write out all data to file on C drive.
File.WriteAllLines(@"tasks.txt", myTasks);
// Read it all back and print out.
foreach (string task in File.ReadAllLines(@"tasks.txt"))
{
Console.WriteLine("TODO: {0}", task);
}
Console.ReadLine();
File.Delete("tasks.txt");
这里的教训是,当你想快速获得一个文件句柄时,File类型将为你节省一些击键。然而,首先创建一个FileInfo对象的一个好处是,您可以使用抽象的FileSystemInfo基类的成员来研究文件。
抽象流类
至此,您已经看到了许多获取FileStream、StreamReader和StreamWriter对象的方法,但是您还没有使用这些类型从文件中读取数据或将数据写入文件。为了理解如何做到这一点,你需要熟悉流的概念。在 I/O 操作的世界里,流代表了在源和目的地之间流动的数据块。流提供了一种与字节序列进行交互的通用方式,无论哪种设备(例如文件、网络连接或打印机)存储或显示相关的字节。
抽象System.IO.Stream类定义了几个成员,这些成员为与存储介质(例如,底层文件或存储位置)的同步和异步交互提供支持。
Note
流的概念不限于文件 I/O。NET 核心库提供了对网络、内存位置和其他以流为中心的抽象的流访问。
同样,Stream后代将数据表示为原始字节流;因此,直接处理原始流是相当神秘的。一些Stream派生的类型支持寻找,是指获取并调整流中当前位置的过程。表 20-7 通过描述Stream类的核心成员来帮助你理解它所提供的功能。
表 20-7。
摘要Stream成员
成员
|
生命的意义
|
| --- | --- |
| CanRead``CanWrite``CanSeek | 确定当前流是否支持读取、查找和/或写入。 |
| Close() | 关闭当前流并释放与当前流关联的任何资源(如套接字和文件句柄)。在内部,这个方法是Dispose()方法的别名;因此,关闭流在功能上等同于处置流。 |
| Flush() | 用缓冲区的当前状态更新底层数据源或储存库,然后清除缓冲区。如果流没有实现缓冲区,则此方法不执行任何操作。 |
| Length | 以字节为单位返回流的长度。 |
| Position | 确定当前流中的位置。 |
| Read()``ReadByte()``ReadAsync() | 从当前流中读取一个字节序列(或单个字节),并将流中的当前位置提升所读取的字节数。 |
| Seek() | 设置当前流中的位置。 |
| SetLength() | 设置当前流的长度。 |
| Write()``WriteByte()``WriteAsync() | 将一个字节序列(或单个字节)写入当前流,并按写入的字节数提升流中的当前位置。 |
使用文件流
FileStream类以适合基于文件的流的方式为抽象Stream成员提供了一个实现。这是一条原始的河流;它只能读取或写入单个字节或字节数组。然而,你并不经常需要与FileStream类型的成员直接交互。相反,你可能会使用各种流包装器,这使得处理文本数据或.NETCore 类型。尽管如此,您会发现尝试一下FileStream类型的同步读/写功能是很有帮助的。
假设您有一个名为 FileStreamApp 的新控制台应用项目(并验证System.IO和System.Text已导入到您的初始 C# 代码文件中)。你的目标是写一个简单的文本信息到一个名为myMessage.dat的新文件中。然而,鉴于FileStream只能对原始字节进行操作,您需要将System.String类型编码到相应的字节数组中。幸运的是,System.Text名称空间定义了一个名为Encoding的类型,它提供了将字符串编码和解码为字节数组的成员。
一旦编码完成,字节数组就用FileStream.Write()方法保存到文件中。要将字节读回内存,必须重置流的内部位置(使用Position属性)并调用ReadByte()方法。最后,向控制台显示原始字节数组和解码后的字符串。以下是完整的代码:
using System;
using System.IO;
using System.Text;
// Don't forget to import the System.Text and System.IO namespaces.
Console.WriteLine("***** Fun with FileStreams *****\n");
// Obtain a FileStream object.
using(FileStream fStream = File.Open("myMessage.dat",
FileMode.Create))
{
// Encode a string as an array of bytes.
string msg = "Hello!";
byte[] msgAsByteArray = Encoding.Default.GetBytes(msg);
// Write byte[] to file.
fStream.Write(msgAsByteArray, 0, msgAsByteArray.Length);
// Reset internal position of stream.
fStream.Position = 0;
// Read the types from file and display to console.
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]);
}
// Display decoded messages.
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基类的核心成员。
表 20-8。
TextWriter 的核心成员
|成员
|
生命的意义
|
| --- | --- |
| Close() | 此方法关闭编写器并释放所有关联的资源。在这个过程中,缓冲区被自动刷新(同样,这个成员在功能上等同于调用Dispose()方法)。 |
| Flush() | 此方法清除当前编写器的所有缓冲区,并将所有缓冲的数据写入基础设备;但是,它不会关闭编写器。 |
| NewLine | 此属性指示派生的 writer 类的换行符常量。Windows 操作系统的默认行结束符是回车,后面跟一个换行符(\r\n)。 |
| Write()``WriteAsync() | 这个重载方法将数据写入文本流,而不使用换行符常量。 |
| WriteLine()``WriteLineAsync() | 这个重载的方法用一个换行符常量将数据写入文本流。 |
Note
最后两个TextWriter类的成员可能你看起来很熟悉。如果您还记得,System.Console类型有Write()和WriteLine()成员,它们将文本数据推送到标准输出设备。实际上,Console.In属性包装了一个TextReader,而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");
// Get a StreamWriter and write string data.
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 + " ");
}
// Insert a new line.
writer.Write(writer.NewLine);
}
Console.WriteLine("Created file and wrote some thoughts...");
Console.ReadLine();
//File.Delete("reminders.txt");
运行该程序后,您可以检查这个新文件的内容。您将在项目的根目录(Visual Studio 代码)或在bin\Debug\net5.0文件夹(Visual Studio)下找到该文件,因为您在调用CreateText()时没有指定绝对路径,并且文件位置默认为程序集的当前执行目录。
从文本文件中读取
接下来,您将学习通过使用相应的StreamReader类型以编程方式从文件中读取数据。回想一下,这个类源自抽象TextReader,它提供了表 20-9 中描述的功能。
表 20-9。
TextReader核心成员
成员
|
生命的意义
|
| --- | --- |
| Peek() | 返回下一个可用字符(用整数表示),而不改变读取器的位置。值-1表示您位于流的末尾。 |
| Read()``ReadAsync() | 从输入流中读取数据。 |
| ReadBlock()``ReadBlockAsync() | 从当前流中读取指定的最大字符数,并将数据写入缓冲区,从指定的索引处开始。 |
| ReadLine()``ReadLineAsync() | 从当前流中读取一行字符,并将数据作为字符串返回(null字符串表示 e of)。 |
| ReadToEnd()``ReadToEndAsync() | 读取从当前位置到流尾的所有字符,并将它们作为单个字符串返回。 |
如果您现在扩展当前的示例应用以使用一个StreamReader,您可以从reminders.txt文件中读入文本数据,如下所示:
Console.WriteLine("***** Fun with StreamWriter / StreamReader *****\n");
...
// Now read data from file.
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()方法获得带有File或FileInfo类型的StreamWriter。碰巧你可以用另一种方式处理StreamWriter和StreamReader s:直接创建它们。例如,您可以对当前应用进行如下改进:
Console.WriteLine("***** Fun with StreamWriter / StreamReader *****\n");
// Get a StreamWriter and write string data.
using(StreamWriter writer = new StreamWriter("reminders.txt"))
{
...
}
// Now read data from file.
using(StreamReader sr = new StreamReader("reminders.txt"))
{
...
}
虽然看到这么多看似相同的文件 I/O 方法会让人有点困惑,但请记住,这样做的结果是更大的灵活性。无论如何,现在您已经准备好检查StringWriter和StringReader类的作用,因为您已经看到了如何使用StreamWriter和StreamReader类型将字符数据移入和移出给定的文件。
使用 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");
// Create a StringWriter and emit character data to memory.
using(StringWriter strWriter = new StringWriter())
{
strWriter.WriteLine("Don't forget Mother's Day this year...");
// Get a copy of the contents (stored in a string) and dump
// to console.
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);
// Get the internal 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);
// Read data from the StringWriter.
using (StringReader strReader = new StringReader(strWriter.ToString()))
{
string input = null;
while ((input = strReader.ReadLine()) != null)
{
Console.WriteLine(input);
}
}
}
使用 binarywriter 和 binaryreader
您将在本节中检查的最后一组写入器/读取器是BinaryReader和BinaryWriter。两者都直接来源于System.Object。这些类型允许您以紧凑的二进制格式读写基础流中的离散数据类型。BinaryWriter类定义了一个高度重载的Write()方法,将数据类型放入底层流中。除了Write()成员之外,BinaryWriter还提供了额外的成员,允许您获取或设置Stream派生的类型;它还支持对数据的随机访问(见表 20-10 )。
表 20-10。
BinaryWriter核心成员
成员
|
生命的意义
|
| --- | --- |
| BaseStream | 这个只读属性提供对与BinaryWriter对象一起使用的基础流的访问。 |
| Close() | 此方法关闭二进制流。 |
| Flush() | 此方法刷新二进制流。 |
| Seek() | 此方法设置当前流中的位置。 |
| Write() | 此方法将一个值写入当前流。 |
BinaryReader类用表 20-11 中描述的成员补充了BinaryWriter提供的功能。
表 20-11。
BinaryReader核心成员
成员
|
生命的意义
|
| --- | --- |
| BaseStream | 这个只读属性提供对与BinaryReader对象一起使用的基础流的访问。 |
| Close() | 此方法关闭二进制读取器。 |
| PeekChar() | 此方法返回下一个可用字符,而不提升在流中的位置。 |
| Read() | 此方法读取一组给定的字节或字符,并将它们存储在传入数组中。 |
| ReadXXXX() | BinaryReader类定义了许多从流中获取下一个类型的读取方法(例如,ReadBoolean()、ReadByte()和ReadInt32())。 |
以下示例(一个名为 BinaryWriterReader 的控制台应用项目,使用了System.IO)将一些数据类型写入一个新的*.dat文件:
using System;
using System.IO;
Console.WriteLine("***** Fun with Binary Writers / Readers *****\n");
// Open a binary writer for a file.
FileInfo f = new FileInfo("BinFile.dat");
using(BinaryWriter bw = new BinaryWriter(f.OpenWrite()))
{
// Print out the type of BaseStream.
// (System.IO.FileStream in this case).
Console.WriteLine("Base stream is: {0}", bw.BaseStream);
// Create some data to save in the file.
double aDouble = 1234.67;
int anInt = 34567;
string aString = "A, B, C";
// Write the data.
bw.Write(aDouble);
bw.Write(anInt);
bw.Write(aString);
}
Console.WriteLine("Done!");
Console.ReadLine();
注意从FileInfo.OpenWrite()返回的FileStream对象是如何传递给BinaryWriter类型的构造函数的。使用这种技术使得在写出数据之前在流中分层变得容易。请注意,BinaryWriter的构造函数接受任何从Stream派生的类型(例如,FileStream、MemoryStream或BufferedStream)。因此,将二进制数据写入内存就像提供一个有效的MemoryStream对象一样简单。
为了从BinFile.dat文件中读取数据,BinaryReader类型提供了几个选项。在这里,您调用各种以读取为中心的成员从文件流中提取每个数据块:
...
FileInfo f = new FileInfo("BinFile.dat");
...
// Read the binary data from the stream.
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委托协同工作。此委托可以调用与以下模式匹配的任何方法:
// The FileSystemEventHandler delegate must point
// to methods matching the following signature.
void MyNotificationHandler(object source, FileSystemEventArgs e)
您还可以使用RenamedEventHandler委托类型来处理Renamed事件,这可以调用匹配以下签名的方法:
// The RenamedEventHandler delegate must point
// to methods matching the following signature.
void MyRenamedHandler(object source, RenamedEventArgs e)
虽然您可以使用传统的委托/事件语法来处理每个事件,但我们将使用新的 lambda 表达式语法。
接下来,我们来看看看一个文件的过程。下面的控制台应用项目(名为 MyDirectoryWatcher,用一个using表示System.IO)监视bin\debug\net5.0目录中的*.txt文件,并在创建、删除、修改或重命名文件时打印消息:
using System;
using System.IO;
Console.WriteLine("***** The Amazing File Watcher App *****\n");
// Establish the path to the directory to watch.
FileSystemWatcher watcher = new FileSystemWatcher();
try
{
watcher.Path = @".";
}
catch(ArgumentException ex)
{
Console.WriteLine(ex.Message);
return;
}
// Set up the things to be on the lookout for.
watcher.NotifyFilter = NotifyFilters.LastAccess
| NotifyFilters.LastWrite
| NotifyFilters.FileName
| NotifyFilters.DirectoryName;
// Only watch text files.
watcher.Filter = "*.txt";
// Add event handlers.
// Specify what is done when a file is changed, created, or deleted.
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}!");
// Specify what is done when a file is renamed.
watcher.Renamed += (s, e) =>
Console.WriteLine($"File: {e.OldFullPath} renamed to {e.FullPath}");
// Begin watching the directory.
watcher.EnableRaisingEvents = true;
// Wait for the user to quit the program.
Console.WriteLine(@"Press 'q' to quit app.");
// Raise some events.
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 核心平台。虽然您肯定会在许多应用中使用这些技术,但是您可能也会发现,对象序列化服务可以极大地简化您持久存储大量数据的方式。
了解对象序列化
术语序列化描述了将对象的状态持久化(并且可能转移)到流(例如,文件流或内存流)中的过程。持久化的数据序列包含了重构(或反序列化)对象的公共状态以备后用所需的所有必要信息。使用这项技术使得保存大量数据变得轻而易举。在许多情况下,使用序列化服务保存应用数据会比使用在System.IO名称空间中找到的读取器/写入器产生更少的代码。
例如,假设您想要创建一个基于 GUI 的桌面应用,为最终用户提供一种保存他们的首选项(例如,窗口颜色和字体大小)的方法。为此,您可以定义一个名为UserPrefs的类,它封装了大约 20 条字段数据。现在,如果你要使用一个System.IO.BinaryWriter类型,你需要手动保存UserPrefs对象的每个字段。同样,如果您要将数据从一个文件加载回内存,您将需要使用一个System.IO.BinaryReader和(再次)手动读入每个值来重新配置一个新的UserPrefs对象。
这都是可行的,但是您可以通过使用可扩展标记语言(XML)或 JavaScript 对象表示法(JSON)序列化来节省大量时间。每种格式都由名称-值对组成,允许在单个文本块中表示对象的公共状态,该文本块可跨平台和编程语言使用。这样做意味着您只需几行代码就可以保持对象的整个公共状态。
Note
本书前几版涉及的BinaryFormatter类型,安全风险较高,应立即停止使用( http://aka.ms/bnaryformatter )。更安全的替代方法包括对 XML/JSON 使用BinaryReader/BinaryWriters。
。NET 核心对象序列化使得持久化对象变得容易;然而,幕后使用的流程相当复杂。例如,当对象被持久保存到流中时,所有相关联的公共数据(例如,基类数据和所包含的对象)也被自动序列化。因此,如果您试图持久化一个派生类,继承链上的所有公共数据都会随之而来。正如您将看到的,您使用一个对象图来表示一组相互关联的对象。
最后,要明白你可以将一个对象图持久化到任何 System.IO.Stream派生的类型中。重要的是数据序列正确地表示了图形中对象的状态。
对象图的作用
如前所述。NET 运行库将考虑所有相关的对象,以确保在序列化对象时公共数据被正确地持久化。这组相关对象被称为对象图。对象图提供了一种简单的方法来记录一组项目如何相互引用。对象图是而不是表示 OOP 是-a 或有-a 关系。相反,您可以将对象图中的箭头理解为“需要”或“依赖”
对象图中的每个对象都被赋予一个唯一的数值。请记住,分配给对象图中成员的数字是任意的,对外界没有实际意义。一旦给所有对象分配了一个数值,对象图就可以记录每个对象的依赖集。
例如,假设您已经创建了一组为一些汽车建模的类(当然)。你有一个名为Car的基类,而有-a Radio。另一个名为JamesBondCar的类扩展了Car的基本类型。图 20-1 显示了模拟这些关系的可能的对象图。
图 20-1。
一个简单的对象图
在读取对象图形时,连接箭头时可以使用短语依赖于或指 到。因此,在图 20-1 中,你可以看到Car指的是Radio类(假定有-a 关系)。JamesBondCar指的是Car(给定是-a 关系),也指的是Radio(它继承了这个受保护的成员变量)。
当然,CLR 不会在内存中绘制图片来表示相关对象的图形。相反,图 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 控制台应用命名为简单序列化。在这个项目中,添加一个名为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<double> 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
{
// A public field.
public bool IsAlive = true;
// A private field.
private int PersonAge = 21;
// Public property/private data.
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");
// Make a JamesBondCar and set state.
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 模式或文档类型定义(DTD)文件定义。
默认情况下,XmlSerializer将所有公共字段/属性序列化为 XML 元素,而不是 XML 属性。如果您想控制XmlSerializer如何生成结果 XML 文档,您可以用任意数量的附加。来自System.Xml.Serialization命名空间的. NET 属性。表 20-12 记录了。NET 核心属性,这些属性影响 XML 数据如何编码为流。
表 20-12。
选择System.Xml.Serialization名称空间的属性
.NET 属性
|
生命的意义
|
| --- | --- |
| [XmlAttribute] | 你可以用这个。NET 属性告诉XmlSerializer将数据序列化为 XML 属性(而不是子元素)。 |
| [XmlElement] | 该字段或属性将被序列化为您选择的 XML 元素。 |
| [XmlEnum] | 此属性提供枚举成员的元素名称。 |
| [XmlRoot] | 该属性控制如何构造根元素(名称空间和元素名称)。 |
| [XmlText] | 属性或字段将被序列化为 XML 文本(即根元素的开始标记和结束标记之间的内容)。 |
| [XmlType] | 该属性提供 XML 类型的名称和命名空间。 |
当然,您可以使用许多其他方法。NET Core 属性来控制XmlSerializer如何生成结果 XML 文档。要了解完整的细节,请在。NET Core SDK 文档。
Note
XmlSerializer要求对象图中的所有序列化类型都支持一个默认的构造函数(所以如果定义了自定义构造函数,一定要把它添加回来)。
使用 XmlSerializer 序列化对象
考虑将以下局部函数添加到您的Program.cs类中:
static void SaveAsXmlFormat<T>(T objGraph, string fileName)
{
//Must declare type in the constructor of the 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 数据:
<?xml version="1.0"?>
<JamesBondCar xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:="http://www.MyCompany.com">
<TheRadio>
<HasTweeters>true</HasTweeters>
<HasSubWoofers>false</HasSubWoofers>
<StationPresets>
<double>89.3</double>
<double>105.1</double>
<double>97.1</double>
</StationPresets>
<RadioId>XF-552RR6</RadioId>
</TheRadio>
<IsHatchBack>false</IsHatchBack>
<CanFly>true</CanFly>
<CanSubmerge>false</CanSubmerge>
</JamesBondCar>
如果您想要指定一个自定义的 XML 名称空间来限定JamesBondCar并将canFly和canSubmerge值编码为 XML 属性,您可以通过修改 C# 对JamesBondCar的定义来实现,如下所示:
[Serializable, XmlRoot(Namespace = "http://www.MyCompany.com")]
public class JamesBondCar : Car
{
[XmlAttribute]
public bool CanFly;
[XmlAttribute]
public bool CanSubmerge;
...
}
这产生了下面的 XML 文档(注意开始的<JamesBondCar>元素):
<?xml version="1.0"""?>
<JamesBondCar xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
CanFly="true" CanSubmerge="false" xmlns:="http://www.MyCompany.com">
...
</JamesBondCar>
接下来,检查下面的PersonData.xml文件:
<?xml version="1.0"?>
<Person xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<IsAlive>true</IsAlive>
<FirstName>James</FirstName>
</Person>
请注意PersonAge属性是如何没有被序列化到 XML 中的。这证实了 XML 序列化只序列化公共属性和字段。
序列化对象集合
既然您已经看到了如何将单个对象持久化到流中,那么您就可以研究如何保存一组对象了。创建一个本地函数,初始化一个JamesBondCars列表,然后将它们序列化为 XML。
static void SaveListOfCarsAsXml()
{
//Now persist a List<T> of JamesBondCars.
List<JamesBondCar> 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<JamesBondCar>));
xmlFormat.Serialize(fStream, myCars);
}
Console.WriteLine("=> Saved list of cars!");
}
最后,添加下面一行来练习新函数:
SaveListOfCarsAsXml();
反序列化对象和对象集合
XML 反序列化实际上与序列化对象(和对象集合)相反。考虑下面的局部函数,将 XML 反序列化回对象图。再次注意,要处理的类型必须传递给XmlSerializer的构造函数:
static T ReadAsXmlFormat<T>(string fileName)
{
// Create a typed instance of the 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<JamesBondCar>("CarData.xml");
Console.WriteLine("Original Car: {0}",savedCar.ToString());
Console.WriteLine("Read Car: {0}",savedCar.ToString());
List<JamesBondCar> savedCars = ReadAsXmlFormat<List<JamesBondCar>>("CarCollection.xml");
用系统进行序列化和反序列化。文本. Json
System.Text.Json名称空间提供了System.Text.Json.JsonSerializer。您可以使用这个格式化程序将给定对象的公共状态持久化为 JSON。
控制生成的 JSON 数据
默认情况下,JsonSerializer使用与对象属性名称相同的名称(和大小写)将所有公共属性序列化为 JSON 名称-值对。您可以使用表 20-13 中列出的最常用属性来控制序列化过程的许多方面。
表 20-13。
选择System.text.Json.Serialization名称空间的属性
.NET 属性
|
生命的意义
|
| --- | --- |
| [JsonIgnore] | 该属性将被忽略。 |
| [JsonInclude] | 将包括该成员。 |
| [JsonPropertyName] | 指定序列化/反序列化成员时要使用的属性名。这通常用于解决字符大小写问题。 |
| [JsonConstructor] | 指示将 JSON 反序列化回对象图时应该使用的构造函数。 |
使用 JsonSerializer 序列化对象
JsonSerializer包含用于转换的静态Serialize方法。NET 核心对象(包括对象图)转换成公共属性的字符串表示形式。在 JavaScript 对象符号中,数据被表示为名称-值对。考虑将以下局部函数添加到您的Program.cs类中:
static void SaveAsJsonFormat<T>(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类来指示JsonSerializer包含所有字段。第二种方法是通过向应该包含在 JSON 输出中的每个公共字段添加[JsonInclude]属性来更新您的类。注意,第一种方法(使用JsonSerializationOptions)将在对象图中包含所有的公共字段。要使用这种技术排除某些公共字段,必须对要排除的字段使用JsonExclude属性。
将 SaveAsJsonFormat 方法更新为以下内容:
static void SaveAsJsonFormat<T>(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<double> 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
{
// A public field.
[JsonInclude]
public bool IsAlive = true;
...
}
现在,当您使用任何一种方法运行代码时,所有公共属性和字段都被写入文件。然而,当你检查内容时,你会看到 JSON 被写成缩小了。 Minified 是一种删除所有无关紧要的空白和换行符的格式。这是默认格式,主要是因为 JSON 广泛用于 RESTful 服务,并且在通过 HTTP/HTTPS 在服务之间发送信息时减少了数据包的大小。
Note
序列化 JSON 的字段处理与反序列化 JSON 相同。如果您选择将选项设置为在序列化 JSON 时包含字段,那么在反序列化 JSON 时也必须包含该选项。
漂亮印刷的 JSON
除了包含公共字段的选项,还可以指示JsonSerializer编写缩进的 JSON(并且是人类可读的)。将您的方法更新为以下内容:
static void SaveAsJsonFormat<T>(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
}
PascalCase 或 camelCase JSON
Pascal 大小写是一种格式,它使用大写的第一个字符以及名称的每个重要部分。以之前的 JSON 清单为例。CanSubmerge是帕斯卡大小写的例子。另一方面,camelCase 将第一个字符设置为小写(就像本节标题中的单词 camelCase ),然后名称的每个重要部分都以大写字母开头。上例的 camel case 版本是canSubmerge。
为什么这很重要?这很重要,因为大多数流行语言都是区分大小写的(比如 C#)。这意味着CanSubmerge和canSubmerge是两个不同的项目。正如你在本书中所看到的,在 C# 中命名公共事物的公认标准(类、公共属性、函数等等。)就是用 Pascal 大小写。然而,大多数 JavaScript 框架更喜欢使用骆驼大小写。使用时,这可能会有问题。NET 和 C# 与其他系统交互,例如通过在 RESTful 服务之间来回传递 JSON。
幸运的是,JsonSerializer可以定制来处理大多数情况,包括大小写差异。如果没有指定命名策略,JsonSerializer在序列化和反序列化 JSON 时将使用 Pascal 大小写。要将序列化过程更改为使用 camel 大小写,请将选项更新为以下内容:
static void SaveAsJsonFormat<T>(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# 是区分大小写的。外壳与Deserialization期间使用的PropertyNamingPolicy的设置相匹配。如果没有设置,则使用默认值(帕斯卡大小写)。通过将PropertyNamingPolicy设置为 camel case,那么所有传入的 JSON 都应该在 camel case 中。如果大小写不匹配,反序列化过程(很快会介绍)就会失败。
反序列化 JSON 时还有第三种选择,那就是大小写无关。通过将PropertyNameCaseInsensitive选项设置为 true,C# 将反序列化canSubmerge和CanSubmerge。下面是设置选项的代码:
JsonSerializerOptions options = new()
{
PropertyNameCaseInsensitive = true,
IncludeFields = true
};
用 JsonSerializer 处理数字
数字的默认处理是严格,这意味着数字将被序列化为数字(不带引号)和反序列化为数字(不带引号)。JsonSerializerOptions有一个NumberHandling属性,控制数字的读写。表 20-14 列出了JsonNumberHandling枚举中的可用值。
表 20-14。
JSON number 处理枚举值
|枚举值
|
生命的意义
|
| --- | --- |
| Strict (0) | 数字是从数字读出来的,写成数字。不允许报价,也不会生成报价。 |
| AllowReadingFromString (1) | 可以从数字或字符串标记中读取数字。 |
| WriteAsString (2) | 数字被写成 JSON 字符串(带引号)。 |
| AllowNamedFloatingPointLiterals (4) | 可以读取Nan、Infinity和-Infinity字符串标记,并且Single和Double值将作为它们对应的 JSON 字符串表示形式写入。 |
enum 有一个flags属性,允许其值的按位组合。例如,如果要读取字符串(和数字)并将数字写成字符串,可以使用以下选项设置:
JsonSerializerOptions options = new()
{
...
NumberHandling = JsonNumberHandling.AllowReadingFromString & JsonNumberHandling.WriteAsString
};
通过这一更改,为Car类创建的 JSON 如下所示:
{
"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<T>(JsonSerializerOptions options, T objGraph, string fileName)
=> File.WriteAllText(fileName, System.Text.Json.JsonSerializer.Serialize(objGraph, options));
JsonSerializer 的 Web 默认值
构建 web 应用时,可以使用专门的构造函数来设置下列属性:
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
NumberHandling = JsonNumberHandling.AllowReadingFromString
您仍然可以通过对象初始化来设置附加属性,如下所示:
JsonSerializerOptions options = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
序列化对象集合
将一个对象集合序列化为 JSON 的过程与单个对象是一样的。将以下局部函数添加到顶级语句的末尾:
static void SaveListOfCarsAsJson(JsonSerializerOptions options, string fileName)
{
//Now persist a List<T> of JamesBondCars.
List<JamesBondCar> 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<T>(JsonSerializerOptions options, string fileName) =>
System.Text.Json.JsonSerializer.Deserialize<T>(File.ReadAllText(fileName), options);
将以下代码添加到顶级语句中,以将 XML 重新组成对象(或对象列表):
JamesBondCar savedJsonCar = ReadAsJsonFormat<JamesBondCar>(options, "CarData.json");
Console.WriteLine("Read Car: {0}", savedJsonCar.ToString());
List<JamesBondCar> savedJsonCars = ReadAsJsonFormat<List<JamesBondCar>>(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 platform 使用一个对象图来描述您希望保存到流中的相关对象的完整集合。然后,您处理了 XML 和 JSON 序列化和反序列化。
二十一、ADO.NET 的数据访问
那个。NET Core platform 定义了几个名称空间,允许您与关系数据库系统进行交互。总的来说,这些名称空间被称为 ADO.NET。在这一章中,您将了解 about 的整体作用以及核心类型和名称空间,然后您将继续讨论 about 数据提供者的主题。那个。NET 核心平台支持许多数据提供者(都是作为。NET 核心框架并可从第三方来源获得),其中每一个都被优化以与特定的数据库管理系统(例如,Microsoft SQL Server、Oracle 和 MySQL)通信。
在理解了各种数据提供者提供的通用功能之后,您将会看到数据提供者工厂模式。正如您将看到的,使用System.Data名称空间中的类型(包括System.Data.Common和特定于提供者的名称空间,如Microsoft.Data.SqlClient、System.Data.Odbc,以及仅用于 Windows 的System.Data.Oledb),您可以构建一个单一的代码库,该代码库可以动态地挑选底层数据提供者,而无需重新编译或重新部署应用的代码库。
接下来,您将学习如何直接使用 SQL Server 数据库提供程序,创建和打开连接以检索数据,然后继续插入、更新和删除数据,最后研究数据库事务主题。最后,您将使用 ADO.NET 执行 SQL Server 的批量复制功能,将记录列表加载到数据库中。
Note
这一章主要讲述未加工的 ADO.NET。从第二十二章开始,我将介绍实体框架(EF)核心,微软的对象关系映射(ORM)框架。由于实体框架核心使用 ADO.NET 进行后台数据访问,因此在排除数据访问故障时,对 ADO.NET 工作方式的深入了解至关重要。还有一些场景是 EF Core 无法解决的(比如执行 SQL 批量复制),你需要了解 ADO.NET 来解决这些问题。
ADO.NET 对阿多
如果您有 Microsoft 以前基于 COM 的数据访问模型(活动数据对象[ADO])的背景,并且刚刚开始使用。NET 核心平台,你需要明白 ADO.NET 除了字母 A 、 D 、 O 之外和 ADO 关系不大。虽然这两个系统之间确实存在一些关系(例如,每个系统都有连接和命令对象的概念),但是一些熟悉的 ADO 类型(例如,Recordset)已经不存在了。此外,您可以找到许多在传统 ADO 下没有直接对等物的新类型(例如,数据适配器)。
了解 ADO.NET 数据提供者
ADO.NET 不提供与多个数据库管理系统(DBMSs)通信的单一对象集。相反,ADO.NET 支持多个数据提供者,其中每一个都被优化为与特定的 DBMS 交互。这种方法的第一个好处是,您可以对特定的数据提供程序进行编程,以访问特定 DBMS 的任何独特功能。第二个好处是,特定的数据提供者可以直接连接到所讨论的 DBMS 的底层引擎,而无需在各层之间设置中间映射层。
简单地说,数据提供者是在给定的名称空间中定义的一组类型,它们知道如何与特定类型的数据源进行通信。无论您使用哪种数据提供程序,都定义了一组提供核心功能的类类型。表 21-1 记录了一些核心基类和它们实现的关键接口。
表 21-1。
ADO.NET 数据提供者的核心对象
|基础类
|
相关接口
|
生命的意义
|
| --- | --- | --- |
| DbConnection | IDbConnection | 提供连接到数据存储和从数据存储断开连接的能力。连接对象还提供对相关事务对象的访问。 |
| DbCommand | IDbCommand | 表示 SQL 查询或存储过程。命令对象还提供对提供程序的数据读取器对象的访问。 |
| DbDataReader | IDataReader,IDataRecord | 使用服务器端游标提供对数据的只进、只读访问。 |
| DbDataAdapter | IDataAdapter,IDbDataAdapter | 在调用者和数据存储器之间传输。数据适配器包含一个连接和一组四个内部命令对象,用于从数据存储中选择、插入、更新和删除信息。 |
| DbParameter | IDataParameter,IDbDataParameter | 表示参数化查询中的命名参数。 |
| DbTransaction | IDbTransaction | 封装数据库事务。 |
尽管这些核心类的具体名称在数据提供者之间会有所不同(例如,SqlConnection与OdbcConnection),但是每个类都是从实现相同接口(例如,IDbConnection)的同一个基类(在连接对象的情况下是DbConnection)中派生出来的。考虑到这一点,您可以正确地假设,在您学会如何使用一个数据提供者之后,其余的提供者都很简单。
Note
当您引用 ADO.NET 下的一个连接对象时,您实际上是在引用一个特定的DbConnection派生类型;没有一个类字面上叫做连接。同样的想法也适用于命令对象、数据适配器对象,等等。作为命名约定,特定数据提供者中的对象以相关 DBMS 的名称为前缀(例如,SqlConnection、SqlCommand和SqlDataReader)。
图 21-1 展示了 ADO.NET 数据提供商背后的大图景。客户端程序集可以是任何类型的。NET 核心应用:控制台程序,Windows 窗体,WPF,ASP.NET 核心。NET 核心代码库等等。
图 21-1。
ADO.NET 数据提供程序提供对给定 DBMS 的访问
除了图 21-1 所示的对象之外,数据提供者将为您提供其他类型;然而,这些核心对象定义了所有数据提供者的公共基线。
ADO.NET 数据提供商
和所有的一样。NET 核心,数据提供者作为 NuGet 包提供。微软和许多第三方提供商都支持这几种软件。表 21-2 记录了微软支持的一些数据提供者。
表 21-2。
一些微软支持的数据提供者
|数据提供者
|
namespace/nu 获取包名
|
| --- | --- |
| 搜寻配置不当的 | Microsoft.Data.SqlClient |
| 开放式数据库连接性 | System.Data.Odbc |
| OLE DB(仅适用于 Windows) | System.Data.OleDb |
微软 SQL Server 数据提供者提供对微软 SQL Server 数据存储的直接访问——并且仅提供对 SQL Server 数据存储(包括 SQL Azure)的直接访问。Microsoft.Data.SqlClient名称空间包含 SQL Server 提供程序使用的类型。
Note
虽然仍然支持System.Data.SqlClient,但是与 SQL Server(和 SQL Azure)交互的所有开发工作都集中在新的Microsoft.Data.SqlClient提供者库上。
ODBC 提供者(System.Data.Odbc)提供对 ODBC 连接的访问。在System.Data.Odbc名称空间中定义的 ODBC 类型通常只有在您需要与没有自定义的给定 DBMS 通信时才有用。NET 核心数据提供者。这是真的,因为 ODBC 是一种广泛使用的模型,它提供了对多个数据存储的访问。
OLE DB 数据提供程序由在System.Data.OleDb命名空间中定义的类型组成,它允许您访问位于任何支持传统的基于 COM 的 OLE DB 协议的数据存储中的数据。由于对 COM 的依赖,此提供程序只能在 Windows 操作系统上工作,在的跨平台环境中应被视为不推荐使用。NET 核心。
系统的类型。数据命名空间
在所有的 ADO.NET 名称空间中,System.Data是最小的公分母。此命名空间包含所有 among 数据提供程序共享的类型,而不考虑基础数据存储。除了许多以数据库为中心的异常(例如,NoNullAllowedException、RowNotInTableException和MissingPrimaryKeyException),System.Data包含表示各种数据库原语(例如,表、行、列和约束)的类型,以及由数据提供者对象实现的公共接口。表 21-3 列出了一些你应该知道的核心类型。
表 21-3。
System.Data名称空间的核心成员
类型
|
生命的意义
|
| --- | --- |
| Constraint | 代表给定DataColumn对象的约束 |
| DataColumn | 表示一个DataTable对象中的一列 |
| DataRelation | 表示两个DataTable对象之间的父子关系 |
| DataRow | 代表一个DataTable对象中的一行 |
| DataSet | 表示由任意数量的相关DataTable对象组成的数据的内存缓存 |
| DataTable | 表示内存中数据的表格块 |
| DataTableReader | 允许您将DataTable视为消防水龙带光标(只进、只读数据访问) |
| DataView | 表示用于排序、过滤、搜索、编辑和导航的DataTable的定制视图 |
| IDataAdapter | 定义数据适配器对象的核心行为 |
| IDataParameter | 定义参数对象的核心行为 |
| IDataReader | 定义数据读取器对象的核心行为 |
| IDbCommand | 定义命令对象的核心行为 |
| IDbDataAdapter | 扩展IDataAdapter以提供数据适配器对象的附加功能 |
| IDbTransaction | 定义事务对象的核心行为 |
你的下一个任务是在高层次上检查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 接口的作用
由IDbConnection定义的重载BeginTransaction()方法提供了对提供者的事务对象的访问。您可以使用由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 接口的作用
注意,IDbCommand的Parameters属性返回一个实现了IDataParameterCollection的强类型集合。该接口提供对一组符合IDbDataParameter的类类型(例如,参数对象)的访问。
public interface IDbDataParameter : IDataParameter
{
//Plus members in the IDataParameter interface
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; }
}
正如您将看到的,IDbDataParameter和IDataParameter接口的功能允许您通过特定的 ADO.NET 参数对象来表示 SQL 命令(包括存储过程)中的参数,而不是通过硬编码的字符串文字。
IDbDataAdapter 和 IDataAdapter 接口的作用
您使用数据适配器将DataSet推送到给定的数据存储,或者从给定的数据存储中拉出。IDbDataAdapter接口定义了以下一组属性,您可以使用这些属性来维护相关选择、插入、更新和删除操作的 SQL 语句:
public interface IDbDataAdapter : IDataAdapter
{
//Plus members of IDataAdapter
IDbCommand SelectCommand { get; set; }
IDbCommand InsertCommand { get; set; }
IDbCommand UpdateCommand { get; set; }
IDbCommand DeleteCommand { get; set; }
}
除了这四个属性之外,ADO.NET 数据适配器还会选择基本接口IDataAdapter中定义的行为。这个接口定义了数据适配器类型的关键功能:使用Fill()和Update()方法在调用者和底层数据存储之间传输DataSet的能力。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
{
//Plus members from IDataRecord
int Depth { get; }
bool IsClosed { get; }
int RecordsAffected { get; }
void Close();
DataTable GetSchemaTable();
bool NextResult();
bool Read();
Dispose();
}
最后,IDataReader扩展了IDataRecord,它定义了许多成员,允许您从流中提取强类型值,而不是强制转换从数据读取器的重载索引器方法中检索的通用System.Object。下面是IDataRecord interface的定义:
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);
}
Note
在尝试从数据读取器获取值之前,可以使用IDataReader.IsDBNull()方法以编程方式发现指定的字段是否设置为null(以避免触发运行时异常)。还记得 C# 支持可空数据类型(参见第四章的,这是与数据库表中可能是null的数据列交互的理想选择。
使用接口抽象数据提供者
至此,您应该对所有这些工具的共同功能有了更好的了解。NET 核心数据提供者。回想一下,尽管实现类型的确切名称在不同的数据提供者之间会有所不同,但是您可以以类似的方式针对这些类型进行编程——这就是基于接口的多态性的美妙之处。例如,如果您定义一个采用IDbConnection参数的方法,您可以传入任何 ADO.NET 连接对象,如下所示:
public static void OpenConnection(IDbConnection cn)
{
// Open the incoming connection for the caller.
connection.Open();
}
Note
接口不是严格要求的;使用抽象基类(如DbConnection)作为参数或返回值,可以达到相同的抽象级别。然而,使用接口而不是基类是普遍接受的最佳实践。
这同样适用于成员返回值。创建新的。NET 核心控制台应用名为 MyConnectionFactory。将以下 NuGet 包添加到项目中(OleDb包仅在 Windows 上有效):
-
Microsoft.Data.SqlClient -
System.Data.Common -
System.Data.Odbc -
System.Data.OleDb
接下来,添加一个名为DataProviderEnum.cs的新文件,并将代码更新如下:
namespace MyConnectionFactory
{
//OleDb is Windows only and is not supported in .NET Core
enum DataProviderEnum
{
SqlServer,
#if PC
OleDb,
#endif
Odbc,
None
}
}
如果您在开发机器上使用 Windows 操作系统,请更新项目文件以定义条件编译器符号PC。
<PropertyGroup>
<DefineConstants>PC</DefineConstants>
</PropertyGroup>
如果您使用的是 Visual Studio,请右击项目,选择“属性”,然后转到“生成”选项卡,输入“条件编译器符号”值。
下面的代码示例允许您基于自定义枚举的值获取特定的连接对象。出于诊断目的,您只需使用反射服务打印底层连接对象。
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); //Not supported on macOS
#endif
Setup(DataProviderEnum.Odbc);
Setup(DataProviderEnum.None);
Console.ReadLine();
void Setup(DataProviderEnum provider)
{
// Get a specific connection.
IDbConnection myConnection = GetConnection(provider);
Console.WriteLine($"Your connection is a {myConnection?.GetType().Name ?? "unrecognized type"}");
// Open, use and close connection...
}
// This method returns a specific connection object
// based on the value of a DataProvider enum.
IDbConnection GetConnection(DataProviderEnum dataProvider)
=> dataProvider switch
{
DataProviderEnum.SqlServer => new SqlConnection(),
#if PC
//Not supported on macOS
DataProviderEnum.OleDb => new OleDbConnection(),
#endif
DataProviderEnum.Odbc => new OdbcConnection(),
_ => null,
};
使用System.Data的通用接口(或者,就此而言,System.Data.Common的抽象基类)的好处是,您有更好的机会构建一个灵活的代码库,可以随着时间的推移而发展。例如,今天您可能正在构建一个面向 Microsoft SQL Server 的应用;但是,您的公司可能会切换到不同的数据库。如果您构建的解决方案对特定于 Microsoft SQL Server 的System.Data.SqlClient类型进行了硬编码,那么您将需要为新的数据库提供者编辑、重新编译和重新部署代码。
至此,您已经编写了一些(相当简单的)point 代码,允许您创建不同类型的特定于提供者的连接对象。然而,获得连接对象只是使用 ADO.NET 的一个方面。要创建一个有价值的数据提供者工厂库,您还必须考虑命令对象、数据读取器、事务对象和其他以数据为中心的类型。构建这样一个代码库并不困难,但是需要大量的代码和时间。
自从发布以来。在. NET 2.0 中,Redmond 的好心人已经将这种精确的功能直接构建到。NET 基础类库。此功能已针对进行了重大更新。NET 核心。
一会儿您将检查这个正式的 API 但是,首先您需要创建一个自定义数据库,以便在本章(以及后面的许多章节)中使用。
设置 SQL Server 和 Azure Data Studio
在学习本章的过程中,您将对一个名为AutoLot的简单 SQL Server 测试数据库执行查询。为了与本书通篇使用的汽车主题保持一致,该数据库将包含五个相互关联的表(Inventory、Makes、Orders、Customers和CreditRisks),这些表包含代表一家虚构的汽车销售公司的信息的各种数据。在了解数据库详细信息之前,您必须设置 SQL Server 和 SQL Server IDE。
Note
如果您使用的是基于 Windows 的开发机器,并且安装了 Visual Studio 2019,那么您还安装了 SQL Server Express 的一个实例(名为localdb),它可以用于本书中的所有示例。如果您愿意使用该版本,请跳到“安装 SQL Server IDE”一节
正在安装 SQL Server
对于本章以及本书中的许多剩余章节,您需要能够访问 SQL Server 的实例。如果您使用的是非基于 Windows 的开发机器,并且没有可用的外部 SQL Server 实例,或者选择不使用外部 SQL Server 实例,则可以在基于 Mac 或 Linux 的工作站上的 Docker 容器中运行 SQL Server。Docker 也可以在 Windows 机器上运行,所以不管你选择什么操作系统,都欢迎你使用 Docker 来运行本书中的例子。
在 Docker 容器中安装 SQL Server
如果您使用的是非基于 Windows 的开发计算机,并且没有可用于示例的 SQL Server 实例,则可以在基于 Mac 或 Linux 的工作站上的 Docker 容器中运行 SQL Server。Docker 也可以在 Windows 机器上运行,所以不管你选择什么操作系统,都欢迎你使用 Docker 来运行本书中的例子。
Note
集装箱化是一个很大的话题,在这本书里没有足够的空间来深入探讨集装箱或码头的细节。这本书将涵盖足够的内容,因此你可以通过例子。
Docker 桌面可以从 www.docker.com/get-started 下载。为您的工作站下载并安装合适的版本(Windows、Mac、Linux)(您需要一个免费的 DockerHub 用户帐户)。确保在出现提示时选择 Linux 容器。
Note
容器选择(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 使用之前下载的映像。
Note
这些不是您想要在实际开发中使用的设置。有关更改 SA 密码的信息以及查看教程,请转至 https://docs.microsoft.com/en-us/sql/linux/quickstart-install-connect-docker?view=sql-server-ver15&pivots=cs1-bash 。
要确认它正在运行,请在命令提示符下输入命令docker ps -a。您将看到如下所示的输出(为简洁起见,省略了一些列):
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 是集装箱 ID 的前五个字符。要重启容器,输入docker start 34747,再次用容器 ID 的开头更新命令。
Note
您还可以在 Docker CLI 命令中使用容器的名称(本例中为AutoLot),例如docker start AutoLot。请注意,无论操作系统如何,Docker 命令都是区分大小写的。
如果您想使用 Docker Dashboard,右键单击 Docker ship(在您的系统托盘中)并选择 Dashboard,您应该会看到在端口 5433 上运行的图像。将鼠标悬停在图像名称上,您将看到停止、启动和删除命令(以及其他命令),如图 21-2 所示。
图 21-2。
Docker 仪表板
正在安装 SQL Server 2019
SQL Server 的一个特殊实例(名为(localdb)\mssqllocaldb)随 Visual Studio 2019 一起安装。如果选择不使用 SQL Server Express LocalDB(或 Docker),并且使用的是 Windows 机器,则可以安装 SQL Server 2019 Developer Edition。SQL Server 2019 开发者版是免费的,可以从这里下载:
https://www.microsoft.com/en-us/sql-server/sql-server-downloads
如果你有另一个版本,你也可以在这本书上使用那个实例;您只需要适当地更改您的连接屏幕。
安装 SQL Server IDE
Azure Data Studio 是一个用于 SQL Server 的新 IDE。它是免费的和跨平台的,所以它可以在 Windows、Mac 或 Linux 上运行。可以从这里下载:
https://docs.microsoft.com/en-us/sql/azure-data-studio/download-azure-data-studio
Note
如果您使用的是 Windows 机器,并且更喜欢使用 SQL Server Management Studio (SSMS),您可以从这里下载最新版本: https://docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms 。
正在连接到 SQL Server
一旦安装了 Azure Data Studio 或 SSMS,就该连接到数据库实例了。以下部分介绍了 Docker 或 LocalDb 容器中 SQL Server 的连接。如果您使用的是 SQL Server 的另一个实例,请相应地更新以下部分中使用的连接字符串。
连接到 Docker 容器中的 SQL Server
要连接到 Docker 容器中运行的 SQL Server 实例,首先要确保它已经启动并正在运行。接下来在 Azure Data Studio 中点击“创建连接”,如图 21-3 所示。
图 21-3。
在 Azure Data Studio 中创建连接
在连接细节对话框中,输入**。,5433"** 为服务器值。圆点表示当前主机,5433 是您在 Docker 容器中创建 SQL Server 实例时指定的端口。输入 sa 作为用户名;并且密码与您在创建 SQL Server 实例时输入的密码相同。该名称是可选的,但允许您在后续 Azure Data Studio 会话中快速选择此连接。图 21-4 显示了这些连接选项。
图 21-4。
为 Docker SQL Server 设置连接选项
正在连接到 SQL Server LocalDb
若要连接到 SQL Server Express LocalDb 的 Visual Studio 安装版本,请更新连接信息以匹配图 21-5 中所示的内容。
图 21-5。
为 SQL Server LocalDb 设置连接选项
当连接到 LocalDb 时,您可以使用 Windows 身份验证,因为该实例与 Azure Data Studio 运行在同一台计算机上,并且与当前登录的用户具有相同的安全上下文。
连接到任何其他 SQL Server 实例
如果要连接到任何其他 SQL Server 实例,请相应地更新连接属性。
恢复汽车人数据库备份
您可以使用 SSMS 或 Azure Data Studio 来恢复包含在存储库中的章节文件中的备份,而不是从头开始构建数据库。提供了两个备份:名为AutoLotWindows.ba_的备份是为在 Windows 机器(LocalDb、Windows Server 等)上使用而设计的。),名为AutoLotDocker.ba_的是为 Docker 容器设计的。
Note
默认情况下,Git 会忽略扩展名为bak的文件。在恢复数据库之前,您需要将扩展名从ba_重命名为bak。
将备份文件复制到容器中
如果在 Docker 容器中使用 SQL Server,首先必须将备份文件复制到容器中。幸运的是,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),而目标是容器名,然后是目录路径(以目标 OS 格式)。
使用 SSMS 恢复数据库
若要使用 SSMS 还原数据库,请在对象资源管理器中右键单击“数据库”节点。选择恢复数据库。选择设备并单击省略号。这将打开“选择备份设备”对话框。
将数据库还原到 SQL Server (Docker)
保持“备份媒体类型”设置为文件,然后单击添加,导航到容器中的AutoLotDocker.bak文件,并单击确定。当你回到主恢复屏幕时,点击确定,如图 21-6 所示。
图 21-6。
使用 SSMS 恢复数据库
将数据库还原到 SQL Server (Windows)
保持“备份媒体类型”设置为文件,然后点击添加,导航至AutoLotWindows.bak,并点击确定。当你回到主恢复屏幕时,点击确定,如图 21-7 所示。
图 21-7。
使用 SSMS 恢复数据库
使用 Azure Data Studio 恢复数据库
要使用 Azure Data Studio 恢复数据库,请单击查看,选择命令面板(或按 Ctrl+Shift+P),然后选择恢复。选择“备份文件”作为“恢复自”选项,然后选择您刚刚复制的文件。目标数据库和相关字段将为您填充,如图 21-8 所示。
图 21-8。
使用 Azure Data Studio 将数据库恢复到 Docker
Note
使用 Azure Data Studio 恢复 Windows 版本备份的过程是相同的。只需调整文件名和路径。
创建汽车人数据库
这一整节致力于使用 Azure Data Studio 创建AutoLot数据库。如果您正在使用 SSMS,您可以使用这里讨论的 SQL 脚本或者使用 GUI 工具来执行这些步骤。如果您恢复了备份,可以跳到“ADO.NET 数据提供者工厂模型”一节
Note
所有的脚本文件都位于 Git 存储库中的一个名为Scripts的文件夹中。
创建数据库
要创建AutoLot数据库,使用 Azure Data Studio 连接到您的数据库服务器。通过选择文件➤新查询(或按 Ctrl+N)并输入以下命令文本来打开新查询:
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
除了将恢复模式更改为 simple 之外,它还使用 SQL Server 默认值创建了AutoLot数据库。单击运行(或按 F5)创建数据库。
创建表
AutoLot数据库包含五个表格:Inventory、Makes、Customers、Orders和CreditRisks。
创建库存表
创建了数据库之后,就该创建表了。首先是Inventory表。打开一个新查询,并输入以下 SQL:
USE [AutoLot]
GO
CREATE TABLE [dbo].Inventory NOT NULL,
[MakeId] [int] NOT NULL,
[Color] nvarchar NOT NULL,
[PetName] nvarchar NOT NULL,
[TimeStamp] [timestamp] NULL,
CONSTRAINT [PK_Inventory] PRIMARY KEY CLUSTERED
(
[Id] ASC
) ON [PRIMARY]
) ON [PRIMARY]
GO
单击运行(或按 F5)创建表。
创建 Makes 表
Inventory表存储(尚未创建)Makes表的外键。创建一个新的查询,并输入以下 SQL 来创建Makes表:
USE [AutoLot]
GO
CREATE TABLE [dbo].Makes NOT NULL,
[Name] nvarchar NOT NULL,
[TimeStamp] [timestamp] NULL,
CONSTRAINT [PK_Makes] PRIMARY KEY CLUSTERED
(
[Id] ASC
) ON [PRIMARY]
) ON [PRIMARY]
GO
单击运行(或按 F5)创建表。
创建客户表
Customers表(顾名思义)将包含一个客户列表。创建一个新查询,并输入以下 SQL 命令:
USE [AutoLot]
GO
CREATE TABLE [dbo].Customers NOT NULL,
[FirstName] nvarchar NOT NULL,
[LastName] nvarchar NOT NULL,
[TimeStamp] [timestamp] NULL,
CONSTRAINT [PK_Customers] PRIMARY KEY CLUSTERED
(
[Id] ASC
) ON [PRIMARY]
) ON [PRIMARY]
GO
点击运行(或按 F5)创建Customers表。
创建订单表
您将使用下一个表Orders来表示给定客户订购的汽车。创建一个新查询,输入以下代码,然后单击 Run(或按 F5 键):
USE [AutoLot]
GO
CREATE TABLE [dbo].Orders 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来代表被认为有信用风险的客户。创建一个新查询,输入以下代码,然后单击 Run(或按 F5 键):
USE [AutoLot]
GO
CREATE TABLE [dbo].CreditRisks NOT NULL,
[FirstName] nvarchar NOT NULL,
[LastName] nvarchar NOT NULL,
[CustomerId] [int] NOT NULL,
[TimeStamp] [timestamp] NULL,
CONSTRAINT [PK_CreditRisks] PRIMARY KEY CLUSTERED
(
[Id] ASC
) ON [PRIMARY]
) ON [PRIMARY]
GO
创建表关系
下一节将添加相关表之间的外键关系。
创建库存以建立关系
打开一个新查询,输入以下 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
创建库存与订单的关系
打开一个新查询,输入以下 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
创建订单到客户的关系
打开一个新查询,输入以下 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
创建客户与信贷风险的关系
打开一个新查询,输入以下 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
Note
如果您想知道为什么有与客户表有关系的列FirstName和LastName 和,这只是为了演示的目的。我可以为它想出一个创造性的理由,但最终,它很好地建立了第二十三章。
创建 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
单击“运行”(或按 F5)创建存储过程。
添加测试记录
没有数据的数据库是相当枯燥的,拥有能够快速将测试记录加载到数据库中的脚本是一个好主意。
制作表格记录
创建一个新的查询并执行以下 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
库存表记录
要将记录添加到您的第一个表中,创建一个新的查询并执行以下 SQL 语句将记录添加到Inventory表中:
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表添加记录,创建一个新的查询并执行以下 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表中。创建一个新查询,输入以下 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数据库完成!当然,这与真实世界的应用数据库相去甚远,但是它将满足您对本章的需求,并将被添加到实体框架核心章节中。既然您已经有了一个要测试的数据库,那么您可以深入研究 ADO.NET 数据提供者工厂模型的细节了。
ADO.NET 数据提供者工厂模型
那个。NET 核心数据提供者工厂模式允许您使用通用的数据访问类型构建一个单一的代码库。为了理解数据提供者工厂实现,从表 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,请使用以下代码:
// Get the factory for the SQL data provider.
DbProviderFactory sqlFactory =
Microsoft.Data.SqlClient.SqlClientFactory.Instance;
为了使程序更加通用,您可以创建一个DbProviderFactory工厂,根据应用的appsettings.json文件中的设置返回一个特定风格的DbProviderFactory。您将很快学会如何做到这一点;目前,一旦获得了数据提供者的工厂,就可以获得相关的特定于提供者的数据对象(例如,连接、命令和数据读取器)。
完整的数据提供者工厂示例
作为一个完整的例子,创建一个新的 C# 控制台应用项目(名为DataProviderFactory),它打印出AutoLot数据库的汽车库存。对于这个初始示例,您将直接在控制台应用中硬编码数据访问逻辑(为了保持简单)。随着本章的深入,你会看到更好的方法。
首先向项目文件中添加一个新的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 操作系统)。
<PropertyGroup>
<DefineConstants>PC</DefineConstants>
</PropertyGroup>
接下来,添加一个名为DataProviderEnum.cs的新文件,并将代码更新如下:
namespace DataProviderFactory
{
//OleDb is Windows only and is not supported in .NET Core
enum DataProviderEnum
{
SqlServer,
#if PC
OleDb,
#endif
Odbc
}
}
将名为appsettings.json的新 JSON 文件添加到项目中,并将其内容更新为以下内容(根据您的特定环境更新连接字符串):
{
"ProviderName": "SqlServer",
//"ProviderName": "OleDb",
//"ProviderName": "Odbc",
"SqlServer": {
// for localdb use @"Data Source=(localdb)\mssqllocaldb;Integrated Security=true; Initial Catalog=AutoLot"
"ConnectionString": "Data Source=.,5433;User Id=sa;Password=P@ssw0rd;Initial Catalog=AutoLot"
},
"Odbc": {
// for localdb use @"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": {
// if localdb use @"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 文件复制到输出目录。通过添加以下内容来更新项目文件:
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
Note
CopyToOutputDirectory是区分空白的。确保所有内容都在一行上,并且单词和之间没有空格。
现在您已经有了一个合适的appsettings.json,您可以使用.NETCore 配置。首先将Program.cs顶部的using语句更新为以下内容:
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);
// Now get the connection object.
using (DbConnection connection = factory.CreateConnection())
{
if (connection == null)
{
Console.WriteLine($"Unable to create the connection object");
return;
}
Console.WriteLine($"Your connection object is a: {connection.GetType().Name}");
connection.ConnectionString = connectionString;
connection.Open();
// Make command object.
DbCommand command = factory.CreateCommand();
if (command == null)
{
Console.WriteLine($"Unable to create the command object");
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 ";
// Print out data with data reader.
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<DataProviderEnum>
(providerName, out DataProviderEnum provider))
{
return (provider,config[$"{providerName}:ConnectionString"]);
};
throw new Exception("Invalid data provider value supplied.");
}
请注意,出于诊断目的,您使用反射服务来打印基础连接、命令和数据读取器的名称。如果您运行这个应用,您将在打印到控制台的AutoLot数据库的Inventory表中找到以下当前数据:
***** 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.
现在修改settings文件来指定一个不同的提供者。除了特定于类型的信息之外,代码将获取相关的连接字符串并产生与以前相同的输出。
当然,根据你使用 ADO.NET 的经验,你可能有点不确定连接、命令和数据读取器对象实际上是做什么的。暂时不要考虑细节(毕竟,这一章还有好几页呢!).至此,知道您可以使用 point 数据提供者工厂模型来构建一个可以以声明方式使用各种数据提供者的单一代码库就足够了。
数据提供者工厂模型的一个潜在缺点
尽管这是一个强大的模型,但是您必须确保代码库只使用通过抽象基类成员对所有提供程序通用的类型和方法。因此,在创作您的代码库时,您只能使用由DbConnection、DbCommand和其他类型的System.Data.Common名称空间公开的成员。
考虑到这一点,您可能会发现这种一般化的方法会阻止您直接访问特定 DBMS 的一些附加功能。如果您必须能够调用基础提供者的特定成员(例如,SqlConnection),您可以使用显式强制转换来实现,如下例所示:
if (connection is SqlConnection sqlConnection)
{
// Print out which version of SQL Server is used.
WriteLine(sqlConnection.ServerVersion);
}
然而,当这样做时,您的代码库变得有点难以维护(并且不太灵活),因为您必须添加许多运行时检查。尽管如此,如果您需要以最灵活的方式构建 doing 数据访问库,数据提供者工厂模型为您提供了一个很好的机制。
Note
Entity Framework Core 及其对依赖注入的支持极大地简化了构建需要访问不同数据源的数据访问库。
有了第一个例子,你现在可以深入了解与 ADO.NET 合作的细节。
深入研究连接、命令和数据读取器
如前面的示例所示,ADO.NET 允许您使用数据提供程序的连接、命令和数据读取器对象与数据库进行交互。现在,您将创建一个扩展示例来更深入地了解 ADO.NET 的这些对象。
在前面演示的示例中,当您想要连接到数据库并使用数据读取器对象读取记录时,需要执行以下步骤:
-
分配、配置和打开您的连接对象。
-
分配和配置命令对象,将连接对象指定为构造函数参数或使用
Connection属性。 -
在配置的命令类上调用
ExecuteReader()。 -
使用数据读取器的
Read()方法处理每条记录。
首先,创建一个名为 AutoLot 的新控制台应用项目。DataReader 并添加Microsoft.Data.SqlClient包。下面是Program.cs内的完整代码(分析随后):
using System;
using Microsoft.Data.SqlClient;
Console.WriteLine("***** Fun with Data Readers *****\n");
// Create and open a connection.
using (SqlConnection connection = new SqlConnection())
{
connection.ConnectionString =
@" Data Source=.,5433;User Id=sa;Password=P@ssw0rd;Initial Catalog=AutoLot";
connection.Open();
// Create a SQL command object.
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);
// Obtain a data reader a la ExecuteReader().
using (SqlDataReader myDataReader = myCommand.ExecuteReader())
{
// Loop over the results.
while (myDataReader.Read())
{
Console.WriteLine($"-> Make: {myDataReader["Make"]}, PetName: {myDataReader ["PetName"]}, Color: {myDataReader["Color"]}.");
}
}
}
Console.ReadLine();
使用连接对象
使用数据提供者的第一步是使用 connection 对象(您记得,它是从DbConnection派生的)与数据源建立会话。。NET Core 连接对象带有格式化的连接字符串;该字符串包含许多名称-值对,用分号分隔。您可以使用这些信息来标识要连接的计算机的名称、所需的安全设置、该计算机上的数据库名称以及其他特定于数据提供程序的信息。
正如您可以从前面的代码中推断的那样,Initial Catalog名称指的是您想要与之建立会话的数据库。Data Source名称标识维护数据库的机器的名称。我使用的是".,5433",它指的是主机(句点,与使用“localhost”相同),端口 5433 是 Docker 容器映射到 SQL Server 端口的端口。如果您使用的是不同的实例,那么您可以将属性定义为machinename,port\instance。例如,MYSERVER\SQLSERVER2019表示MYSERVER是运行 SQL Server 的服务器的名称,默认端口正在使用,而SQLSERVER2019是实例的名称。如果机器是本地的,您可以使用句点(.)或标记(localhost)作为服务器名称。如果 SQL Server 实例是默认实例,则不使用实例名称。例如,如果您在 Microsoft SQL Server 安装上创建了AutoLot,并将其设置为本地计算机上的默认实例,那么您将使用"Data Source=localhost"。
除此之外,您可以提供任意数量的表示安全凭证的令牌。如果Integrated Security设置为true,则当前 Windows 帐户凭证用于验证和授权。
建立连接字符串后,可以调用Open()来建立与 DBMS 的连接。除了ConnectionString、Open()和Close()成员之外,连接对象还提供了许多成员,允许您配置关于连接的附加设置,比如超时设置和事务信息。表 21-4 列出了DbConnection基类的一些(但不是全部)成员。
表 21-4。
DbConnection类型的成员
成员
|
生命的意义
|
| --- | --- |
| BeginTransaction() | 您使用此方法开始数据库事务。 |
| ChangeDatabase() | 您可以使用此方法在打开的连接上更改数据库。 |
| ConnectionTimeout | 此只读属性返回建立连接时,在终止连接并生成错误之前等待的时间(默认值取决于提供程序)。如果您想更改默认值,请在连接字符串中指定一个Connect Timeout段(例如Connect Timeout=30)。 |
| Database | 此只读属性获取由 connection 对象维护的数据库的名称。 |
| DataSource | 此只读属性获取由 connection 对象维护的数据库的位置。 |
| GetSchema() | 该方法返回一个包含来自数据源的模式信息的DataTable对象。 |
| State | 这个只读属性获取连接的当前状态,由ConnectionState枚举表示。 |
DbConnection类型的属性本质上通常是只读的,只有当您想要在运行时获得连接的特征时才有用。当需要重写默认设置时,必须更改连接字符串本身。例如,以下连接字符串将connection timeout设置从默认值(SQL Server 为 15 秒)设置为 30 秒:
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)
{
// Show various stats about current connection object.
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 的数据提供者支持连接字符串生成器对象,它允许您使用强类型属性建立名称-值对。考虑以下对当前code的更新:
var connectionStringBuilder = new SqlConnectionStringBuilder
{
InitialCatalog = "AutoLot",
DataSource = ".,5433",
UserID = "sa",
Password = "P@ssw0rd",
ConnectTimeout = 30
};
connection.ConnectionString =
connectionStringBuilder.ConnectionString;
在这个迭代中,您创建一个SqlConnectionStringBuilder的实例,相应地设置属性,并使用ConnectionString属性获得内部字符串。另请注意,您使用了该类型的默认构造函数。如果您愿意,还可以通过传入现有连接字符串作为起点来创建数据提供程序的连接字符串生成器对象的实例(当您从外部源动态读取这些值时,这可能会很有帮助)。一旦使用初始字符串数据对对象进行了合并,就可以使用相关属性更改特定的名称-值对。
使用命令对象
既然您已经更好地理解了 connection 对象的角色,下一步工作就是检查如何向相关数据库提交 SQL 查询。SqlCommand类型(从DbCommand派生而来)是 SQL 查询、表名或存储过程的面向对象表示。使用CommandType属性指定命令的类型,该属性可以从CommandType枚举中获取任何值,如下所示:
public enum CommandType
{
StoredProcedure,
TableDirect,
Text // Default value.
}
当您创建一个命令对象时,您可以将 SQL 查询建立为一个构造函数参数,或者直接使用CommandText属性。此外,在创建命令对象时,需要指定要使用的连接。同样,您可以通过构造函数参数或使用Connection属性来实现。考虑以下代码片段:
// Create command object via ctor args.
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);
// Create another command object via properties.
SqlCommand testCommand = new SqlCommand();
testCommand.Connection = connection;
testCommand.CommandText = sql;
要知道,在这一点上,您实际上并没有将 SQL 查询提交给AutoLot数据库,而是准备了命令对象的状态以备将来使用。表 21-5 突出显示了DbCommand类型的一些附加成员。
表 21-5。
DbCommand类型的成员
成员
|
生命的意义
|
| --- | --- |
| CommandTimeout | 获取或设置在终止尝试并生成错误之前执行命令时等待的时间。默认值为 30 秒。 |
| Connection | 获取或设置此DbCommand实例使用的DbConnection。 |
| Parameters | 获取用于参数化查询的DbParameter对象的集合。 |
| Cancel() | 取消命令的执行。 |
| ExecuteReader() | 执行 SQL 查询并返回数据提供者的DbDataReader对象,该对象提供对查询结果的只进、只读访问。 |
| ExecuteNonQuery() | 执行 SQL 非查询(例如,插入、更新、删除或创建表)。 |
| ExecuteScalar() | 一个轻量级版本的ExecuteReader()方法,它是专门为单独查询设计的(例如,获取记录计数)。 |
| Prepare() | 在数据源上创建命令的准备(或编译)版本。您可能知道,准备好的查询执行起来稍微快一些,当您需要多次执行相同的查询(通常每次使用不同的参数)时会很有用。 |
使用数据读取器
建立活动连接和 SQL 命令后,下一步是向数据源提交查询。正如您可能猜到的,您有许多方法可以做到这一点。DbDataReader类型(实现了IDataReader)是从数据存储中获取信息的最简单快捷的方式。回想一下,数据读取器表示只读、只进的数据流,一次返回一条记录。鉴于此,数据读取器只有在向基础数据存储提交 SQL 选择语句时才有用。
当您需要快速迭代大量数据并且不需要维护内存中的表示时,数据读取器非常有用。例如,如果您请求将一个表中的 20,000 条记录存储在一个文本文件中,那么将这些信息保存在一个DataSet中会占用大量内存(因为一个DataSet同时将整个查询结果保存在内存中)。
一个更好的方法是创建一个数据读取器,尽可能快地旋转每条记录。但是,请注意,数据读取器对象(与数据适配器对象不同,您将在后面研究数据适配器对象)会保持与其数据源的打开连接,直到您显式关闭该连接。
您通过调用ExecuteReader()从命令对象获得数据读取器对象。数据读取器表示它从数据库中读取的当前记录。数据读取器有一个索引器方法(例如,C# 中的[]语法),允许您访问当前记录中的一列。您可以通过名称或从零开始的整数来访问该列。
数据读取器的以下使用利用了Read()方法来确定何时到达记录的末尾(使用一个false返回值)。对于从数据库中读取的每个传入记录,使用类型索引器打印出每辆汽车的品牌、昵称和颜色。还要注意,一旦处理完记录,就调用Close(),这释放了连接对象。
...
// Obtain a data reader via ExecuteReader().
using(SqlDataReader myDataReader = myCommand.ExecuteReader())
{
// Loop over the results.
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();
}
如果您在此时编译并运行您的项目,您应该会在AutoLot数据库的Inventory表中看到所有汽车的列表。
***** 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;";
Note
开头的分号不是错别字。使用多个语句时,它们必须用分号分隔。因为最初的语句不包含,所以在第二个语句的开头添加了一个。
获得数据读取器后,可以使用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()方法。这个方法根据命令文本的格式执行插入、更新和删除操作。
Note
从技术上讲,非查询是一个不返回结果集的 SQL 语句。因此,Select语句是查询,而Insert、Update和Delete语句不是查询。鉴于此,ExecuteNonQuery()返回一个代表受影响的行数的int,而不是一组新的记录。
到目前为止,本章中的所有数据库交互示例都只打开了连接,并使用它们来检索数据。这只是使用数据库的一部分;除非数据访问框架也完全支持创建、读取、更新和删除(CRUD)功能,否则它没有多大用处。接下来,您将学习如何使用对ExecuteNonQuery()的调用来做到这一点。
首先创建一个名为 AutoLot 的新 C# 类库项目。dal(AutoLot 数据访问层的简称),删除默认的类文件,将Microsoft.Data.SqlClient包添加到项目中。
在构建将执行数据操作的类之前,我们将首先创建一个 C# 类,它表示来自Inventory表的记录及其相关的Make信息。
创建 Car 和 CarViewModel 类
现代数据访问库使用类(通常称为模型或实体)来表示和传输数据库中的数据。此外,可以使用类来表示数据的视图,该视图将两个或更多的表组合在一起,使数据更有意义。实体类用于处理数据库目录(用于更新语句),视图模型类用于以有意义的方式显示数据。在下一章中,您将看到这些概念是像实体框架核心这样的对象关系映射器(ORM)的基础,但是现在,您只需创建一个模型(针对原始库存行)和一个视图模型(将库存行与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; }
}
}
Note
如果您不熟悉 SQL Server TimeStamp数据类型(在 C# 中,它映射到一个byte[]),此时不必担心。只知道它用于行级并发检查,会被实体框架核心覆盖。
这些类将很快被使用。
添加 InventoryDal 类
接下来,添加一个名为DataOperations的新文件夹。在这个新文件夹中,添加一个名为InventoryDal.cs的新类,并将该类更改为public。这个类将定义各种成员来与AutoLot数据库的Inventory表交互。最后,导入以下名称空间:
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块来处理可能的异常,也不会抛出自定义异常来报告执行中的各种问题(例如,格式错误的连接字符串)。如果你要构建一个工业级的数据访问库,你绝对会想要使用结构化异常处理技术(如第七章所述)来解决任何运行时异常。
添加 IDisposable
将IDisposable接口添加到类定义中,如下所示:
public class InventoryDal : IDisposable
{
...
}
接下来,实现一次性模式,在SqlConnection对象上调用Dispose。
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);
}
添加选择方法
首先,结合您已经知道的关于Command对象、DataReader和通用集合的知识,从Inventory表中获取记录。正如您在本章前面所看到的,数据提供者的数据读取器对象允许使用只读、只进机制和Read()方法来选择记录。在这个例子中,DataReader上的CommandBehavior属性被设置为当阅读器关闭时自动关闭连接。GetAllInventory()方法返回一个List<CarViewModel>来表示Inventory表中的所有数据。
public List<CarViewModel> GetAllInventory()
{
OpenConnection();
// This will hold the records.
List<CarViewModel> inventory = new List<CarViewModel>();
// Prep command object.
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;
}
下一个选择方法基于CarId获得单个CarViewModel。
public CarViewModel GetCar(int id)
{
OpenConnection();
CarViewModel car = null;
//This should use parameters for security reasons
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;
}
Note
像这里所做的那样,接受用户输入到原始 SQL 语句中通常是一种不好的做法。在本章的后面,这段代码将被更新以使用参数。
插入一辆新车
向Inventory表中插入一条新记录非常简单,只需格式化 SQL Insert语句(基于用户输入),打开连接,使用命令对象调用ExecuteNonQuery(),然后关闭连接。您可以通过向名为InsertAuto()的InventoryDal类型添加一个公共方法来看到这一点,该方法采用三个参数映射到Inventory表的不相同列(Color、Make和PetName)。您可以使用这些参数来格式化字符串类型,以便插入新记录。最后,使用您的SqlConnection对象来执行 SQL 语句。
public void InsertAuto(string color, int makeId, string petName)
{
OpenConnection();
// Format and execute SQL statement.
string sql = $"Insert Into Inventory (MakeId, Color, PetName) Values ('{makeId}', '{color}', '{petName}')";
// Execute using our connection.
using (SqlCommand command = new SqlCommand(sql, _sqlConnection))
{
command.CommandType = CommandType.Text;
command.ExecuteNonQuery();
}
CloseConnection();
}
前面的方法为Car取三个值,只要调用代码以正确的顺序传递这些值,它就能工作。一个更好的方法是使用Car来创建一个强类型方法,确保所有的属性都以正确的顺序传递到方法中。
创建强类型 InsertCar()方法
向您的InventoryDal类添加另一个将Car作为参数的InsertAuto()方法,如下所示:
public void InsertAuto(Car car)
{
OpenConnection();
// Format and execute SQL statement.
string sql = "Insert Into Inventory (MakeId, Color, PetName) Values " +
$"('{car.MakeId}', '{car.Color}', '{car.PetName}')";
// Execute using our connection.
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();
// Get ID of car to delete, then do so.
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();
// Get ID of car to modify the pet name.
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 字符串快得多,因为它们只被解析一次(而不是每次 SQL 字符串被分配给CommandText属性)。参数化查询还有助于防范 SQL 注入攻击(一个众所周知的数据访问安全问题)。
为了支持参数化查询,ADO.NET 命令对象维护单个参数对象的集合。默认情况下,这个集合是空的,但是您可以插入任意数量的参数对象,这些对象映射到 SQL 查询中的一个占位符参数。当您想要将 SQL 查询中的参数与 command 对象的 parameters 集合中的成员相关联时,您可以在 SQL 文本参数前面加上符号@(至少在使用 Microsoft SQL Server 时是这样;并非所有 DBMSs 都支持这种表示法)。
使用 DbParameter 类型指定参数
在构建参数化查询之前,您需要熟悉DbParameter类型(它是提供者的特定参数对象的基类)。该类维护许多属性,这些属性允许您配置参数的名称、大小和数据类型,以及其他特征,包括参数的行进方向。表 21-6 描述了DbParameter型的一些关键特性。
表 21-6。
DbParameter类型的主要成员
财产
|
生命的意义
|
| --- | --- |
| DbType | 获取或设置参数的本机数据类型,表示为 CLR 数据类型 |
| Direction | 获取或设置参数是仅输入、仅输出、双向还是返回值参数 |
| IsNullable | 获取或设置参数是否接受空值 |
| ParameterName | 获取或设置DbParameter的名称 |
| Size | 获取或设置数据的最大参数大小(以字节为单位);这仅对文本数据有用 |
| Value | 获取或设置参数的值 |
现在让我们看看如何通过修改InventoryDal方法来使用参数,从而填充一个命令对象的DBParameter兼容对象的集合。
更新 GetCar 方法
在构建 SQL 字符串来检索汽车数据时,GetCar()方法的最初实现使用了 C# 字符串插值法。要更新这个方法,用适当的值创建一个SqlParameter的实例,如下所示:
SqlParameter param = new SqlParameter
{
ParameterName = "@carId",
Value = id,
SqlDbType = SqlDbType.Int,
Direction = ParameterDirection.Input
};
ParameterName值必须与 SQL 查询中使用的名称匹配(接下来您将更新它),类型必须与数据库列类型匹配,方向取决于参数是用于将数据发送到查询ParameterDirection.Input的中,还是用于从查询(ParameterDirection.Output)返回数据*。参数也可以定义为输入/输出或返回值(例如,来自存储过程)。*
接下来,更新 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";
最后的更新是将新参数添加到 command 对象的Parameters集合中。
command.Parameters.Add(param);
更新 DeleteCar 方法
同样,DeleteCar()方法的最初实现使用了 C# 字符串插值。要更新这个方法,用适当的值创建一个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";
最后的更新是将新参数添加到 command 对象的Parameters集合中。
command.Parameters.Add(param);
更新 UpdateCarPetName 方法
这个方法需要两个参数,一个是汽车Id的,另一个是新PetName的。第一个参数的创建与前两个示例一样(除了不同的变量名),第二个参数创建一个映射到数据库NVarChar类型的参数(来自Inventory表的PetName字段类型)。请注意,设置了一个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";
最后的更新是将新参数添加到 command 对象的Parameters集合中。
command.Parameters.Add(paramId);
command.Parameters.Add(paramName);
更新 internauto 方法
添加以下版本的InsertAuto()方法来利用参数对象:
public void InsertAuto(Car car)
{
OpenConnection();
// Note the "placeholders" in the SQL query.
string sql = "Insert Into Inventory" +
"(MakeId, Color, PetName) Values" +
"(@MakeId, @Color, @PetName)";
// This command will have internal parameters.
using (SqlCommand command = new SqlCommand(sql, _sqlConnection))
{
// Fill params collection.
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类型的最后一个方法,它调用您的存储过程:
public string LookUpPetName(int carId)
{
OpenConnection();
string carPetName;
// Establish name of stored proc.
using (SqlCommand command = new SqlCommand("GetPetName", _sqlConnection))
{
command.CommandType = CommandType.StoredProcedure;
// Input param.
SqlParameter param = new SqlParameter
{
ParameterName = "@carId",
SqlDbType = SqlDbType.Int,
Value = carId,
Direction = ParameterDirection.Input
};
command.Parameters.Add(param);
// Output param.
param = new SqlParameter
{
ParameterName = "@petName",
SqlDbType = SqlDbType.NVarChar,
Size = 50,
Direction = ParameterDirection.Output
};
command.Parameters.Add(param);
// Execute the stored proc.
command.ExecuteNonQuery();
// Return output param.
carPetName = (string)command.Parameters["@petName"].Value;
CloseConnection();
}
return carPetName;
}
调用存储过程的一个重要方面是要记住,命令对象可以表示 SQL 语句(默认)或存储过程的名称。当你想通知一个命令对象它将调用一个存储过程时,你传入该过程的名称(作为一个构造函数参数或者通过使用CommandText属性)并且必须将CommandType属性设置为值CommandType.StoredProcedure。(如果您未能做到这一点,您将会收到一个运行时异常,因为默认情况下命令对象需要一个 SQL 语句。)
接下来,请注意,@petName参数的Direction属性被设置为ParameterDirection.Output。和前面一样,将每个参数对象添加到命令对象的参数集合中。
在存储过程通过调用ExecuteNonQuery()完成之后,您可以通过研究命令对象的参数集合和相应的转换来获得输出参数的值。
// Return output param.
carPetName = (string)command.Parameters["@petName"].Value;
至此,您已经有了一个极其简单的数据访问库,可以用它来构建一个显示和编辑数据的客户机。您还没有研究如何构建图形用户界面,所以接下来您将从新的控制台应用测试您的数据库。
创建基于控制台的客户端应用
向AutoLot.Dal解决方案添加一个新的控制台应用(名为AutoLot.Client)并添加一个对AutoLot.Dal项目的引用。完成此任务的dotnet CLI 命令如下(假设您的解决方案名为Chapter21_AllProjects.sln):
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,右击您的解决方案并选择“添加➤新项目”。将新项目设置为启动项目(通过在解决方案资源管理器中右击该项目并选择“设置为启动项目”)。这将在 Visual Studio 中调试时运行您的新项目。如果您使用的是 Visual Studio 代码,您需要导航到AutoLot.Test目录并使用dotnet run运行项目(当时间到了的时候)。
清除Program.cs中生成的代码,并将下面的using语句添加到Program.cs的顶部:
using System;
using System.Linq;
using AutoLot.Dal;
using AutoLot.Dal.Models;
using AutoLot.Dal.DataOperations;
using System.Collections.Generic;
用下面的代码替换Main()方法来练习AutoLot.Dal:
InventoryDal dal = new InventoryDal();
List<CarViewModel> 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
{
//This will fail because of related data in the Orders table
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 美元从您的储蓄账户转入您的支票账户,则应以交易方式执行以下步骤:
-
银行应该从你的储蓄账户中取出 500 美元。
-
银行应该在你的支票账户上加 500 美元。
如果钱从储蓄账户中取出,但没有转到支票账户(因为银行方面的一些错误),这将是一件非常糟糕的事情,因为那样你将损失 500 美元!但是,如果这些步骤被打包到一个数据库事务中,DBMS 会确保所有相关步骤作为一个单元发生。如果事务的任何部分失败,整个操作将回滚到原始状态。另一方面,如果所有步骤都成功了,事务就会被提交。
Note
您可能在阅读交易文献时熟悉缩写 ACID。这代表了一个适当的事务的四个关键属性:原子的(全有或全无)、一致的(数据在整个事务中保持稳定)、隔离的(事务不干扰其他操作)、以及持久的(事务被保存和记录)。
事实证明。NET 核心平台支持多种方式的交易。本章将研究您的 ADO.NET 数据提供者的事务对象(在Microsoft.Data.SqlClient的情况下,是SqlTransaction)。
除了内置的事务支持之外。NET 基本类库,可以使用数据库管理系统的 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()方法,通知 DBMS 忽略任何挂起的更改,保持原始数据不变。
Note
transaction 对象的IsolationLevel属性允许您指定一个事务应该如何积极地防范其他并行事务的活动。默认情况下,事务在提交之前是完全隔离的。
除了由IDbTransaction接口定义的成员之外,SqlTransaction类型还定义了一个名为Save()的额外成员,它允许您定义保存点。这个概念允许您将失败的事务回滚到指定的点,而不是回滚整个事务。本质上,当您使用SqlTransaction对象调用Save()时,您可以指定一个友好的字符串名字对象。当您调用Rollback()时,您可以指定这个相同的名字作为参数来执行有效的部分回滚。不带参数调用Rollback()会导致所有挂起的更改回滚。
将交易方法添加到库存
现在让我们看看如何以编程方式处理 ADO.NET 事务。首先打开您之前创建的AutoLot.Dal代码库项目,并将一个名为ProcessCreditRisk()的新公共方法添加到InventoryDal类中,以处理感知的信用风险。该方法将查找一个客户,将他们添加到CreditRisks表中,然后通过在末尾添加“(信用风险)”来更新他们的姓氏。
public void ProcessCreditRisk(bool throwEx, int customerId)
{
OpenConnection();
// First, look up current name based on customer ID.
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();
// Create command objects that represent each step of the operation.
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);
// We will get this from the connection object.
SqlTransaction tx = null;
try
{
tx = _sqlConnection.BeginTransaction();
// Enlist the commands into this transaction.
cmdInsert.Transaction = tx;
cmdUpdate.Transaction = tx;
// Execute the commands.
cmdInsert.ExecuteNonQuery();
cmdUpdate.ExecuteNonQuery();
// Simulate error.
if (throwEx)
{
throw new Exception("Sorry! Database error! Tx failed...");
}
// Commit it!
tx.Commit();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
// Any error will roll back transaction. Using the new conditional access operator to check for null.
tx?.Rollback();
}
finally
{
CloseConnection();
}
}
这里,您使用一个传入的bool参数来表示当您试图处理违规的客户时是否会抛出一个任意的异常。这允许您模拟会导致数据库事务失败的意外情况。显然,您在这里这样做只是为了说明的目的;真正的数据库事务方法不会允许调用者心血来潮地强迫逻辑失败!
注意,您使用两个SqlCommand对象来表示您将开始的事务中的每一步。在根据传入的customerID参数获得客户的名字和姓氏之后,可以使用BeginTransaction()从连接对象中获得一个有效的SqlTransaction对象。接下来,也是最重要的,您必须通过将Transaction属性分配给您刚刚获得的事务对象来登记每个命令对象。如果您没有这样做,Insert / Update逻辑将不会在事务上下文中。
在每个命令上调用ExecuteNonQuery()之后,当(且仅当)参数bool的值为true时,抛出异常。在这种情况下,所有挂起的数据库操作都将回滚。如果您没有抛出异常,那么一旦您调用了Commit(),这两个步骤都将被提交给数据库表。
测试您的数据库事务
选择您添加到 customers 表中的一个客户(例如,Dave Benner,Id = 1)。接下来,在自动 Lot 中添加一个新方法到Program.cs。客户项目名为FlagCustomer()。
void FlagCustomer()
{
Console.WriteLine("***** Simple Transaction Example *****\n");
// A simple way to allow the tx to succeed or not.
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();
// Process customer 1 – enter the id for the customer to move.
dal.ProcessCreditRisk(throwEx, 1);
Console.WriteLine("Check CreditRisk table for results");
Console.ReadLine();
}
如果您要运行您的程序并选择抛出一个异常,您会发现客户的姓是*,而不是Customers表中的*,因为整个事务已经回滚。但是,如果您没有抛出异常,您会发现客户的姓氏在Customers表中被更新,并被添加到CreditRisks表中。
使用 ADO.NET 执行批量复制
在需要将大量记录加载到数据库中的情况下,目前显示的方法效率相当低。SQL Server 有一个名为批量复制的特性,它是专门为这个场景设计的,在 ADO.NET 中用SqlBulkCopy类包装。本章的这一节将介绍如何使用 ADO.NET 来实现这一点。
探索 SqlBulkCopy 类
SqlBulkCopy类有一个方法WriteToServer()(和异步版本WriteToServerAsync()),它处理记录列表并将数据写入数据库,比编写一系列insert语句并用Command对象运行它们更有效。WriteToServer重载使用一个DataTable、一个DataReader或一个DataRow数组。为了与本章的主题保持一致,您将使用DataReader版本。为此,您需要创建一个定制的数据读取器。
创建自定义数据读取器
您希望您的自定义数据读取器是通用的,并保存您要导入的模型列表。首先在名为BulkImport的AutoLot.Dal项目中创建一个新文件夹;在文件夹中,创建一个名为IMyDataReader.cs的实现IDataReader的新接口类,并将代码更新如下:
using System.Collections.Generic;
using System.Data;
namespace AutoLot.Dal.BulkImport
{
public interface IMyDataReader<T> : IDataReader
{
List<T> Records { get; set; }
}
}
接下来是实现自定义数据读取器的任务。正如您已经看到的,数据读取器有许多活动部件。对你来说好消息是,对于SqlBulkCopy,你只需要实现其中的一小部分。创建一个名为MyDataReader.cs的新类,并添加以下using语句:
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Reflection;
接下来,将类更新为 public 和 sealed 并实现IMyDataReader。添加一个构造函数来接收记录并设置属性。
public sealed class MyDataReader<T> : IMyDataReader<T>
{
public List<T> Records { get; set; }
public MyDataReader(List<T> records)
{
Records = records;
}
}
让 Visual Studio 或 Visual Studio 代码为您实现所有方法(或从下面复制它们),您就有了自定义数据读取器的起点。表 21-7 详细说明了这种情况下需要实现的唯一方法。
表 21-7。
IDataReader的关键方法为SqlBulkCopy
方法
|
生命的意义
|
| --- | --- |
| Read | 获取下一条记录;如果有另一条记录,则返回true,如果在列表末尾,则返回false |
| FieldCount | 获取数据源中的字段总数 |
| GetValue | 根据序号位置获取字段的值 |
| GetSchemaTable | 获取目标表的架构信息 |
从Read()方法开始,如果读取器在列表的末尾,则返回false,如果读取器不在列表的末尾,则返回true(并增加一个类级计数器)。添加一个类级变量来保存List<T>的当前索引,并更新Read()方法,如下所示:
public class MyDataReader<T> : IMyDataReader<T>
{
...
private int _currentIndex = -1;
public bool Read()
{
if (_currentIndex + 1 >= Records.Count)
{
return false;
}
_currentIndex++;
return true;
}
}
get方法和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,
};
}
数据库只有四个表,但这意味着您仍然有四种不同的数据读取器。想象一下,如果您有一个包含更多表的真正的生产数据库!你可以使用反射(在第十七章中介绍)和对象 LINQ(在第十三章中介绍)做得更好。
添加readonly变量来保存模型的PropertyInfo值,并添加一个字典来保存 SQL Server 中表的字段位置和名称。更新构造函数以获取泛型类型的属性并初始化Dictionary。添加的代码如下:
private readonly PropertyInfo[] _propertyInfos;
private readonly Dictionary<int, string> _nameDictionary;
public MyDataReader(List<T> records)
{
Records = records;
_propertyInfos = typeof(T).GetProperties();
_nameDictionary = new Dictionary<int,string>();
}
接下来,更新构造函数以获取一个SQLConnection以及模式的字符串和记录将要插入的表的表名,并为值添加类级别的变量。
private readonly SqlConnection _connection;
private readonly string _schema;
private readonly string _tableName;
public MyDataReader(List<T> records, SqlConnection connection, string schema, string tableName)
{
Records = records;
_propertyInfos = typeof(T).GetProperties();
_nameDictionary = new Dictionary<int, string>();
_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<T> records, SqlConnection connection, string schema, string tableName)
{
...
DataTable schemaTable = GetSchemaTable();
for (int x = 0; x<schemaTable?.Rows.Count;x++)
{
DataRow col = schemaTable.Rows[x];
var columnName = col.Field<string>("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的新public static类。将以下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()。这里显示了ExecuteBulkImport方法:
public static void ExecuteBulkImport<T>(IEnumerable<T> records, string tableName)
{
OpenConnection();
using SqlConnection conn = _sqlConnection;
SqlBulkCopy bc = new SqlBulkCopy(conn)
{
DestinationTableName = tableName
};
var dataReader = new MyDataReader<T>(records.ToList(),_sqlConnection, "dbo",tableName); try
{
bc.WriteToServer(dataReader);
}
catch (Exception ex)
{
//Should do something here
}
finally
{
CloseConnection();
}
}
测试批量副本
回到AutoLot.Client项目,向Program.cs添加以下using语句:
using AutoLot.Dal.BulkImport;
using SystemCollections.Generic;
给Program.cs添加一个新方法,名为DoBulkCopy()。创建一个Car对象的列表,并将该列表(以及表的名称)传递给ExecuteBulkImport()方法。其余代码显示批量复制的结果。
void DoBulkCopy()
{
Console.WriteLine(" ************** Do Bulk Copy ************** ");
var cars = new List<Car>
{
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<CarViewModel> 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();
}
虽然添加四辆新车并不能展示使用SqlBulkCopy类所涉及的工作的价值,但是想象一下试图加载数千条记录。我曾经和客户这样做过,加载时间只有几秒钟,而遍历每条记录需要几个小时!就像所有的东西一样。NET Core,这只是您工具箱中的另一个工具,在最有意义的时候使用。
摘要
ADO.NET 是本地的数据访问技术。NET 核心平台。在这一章中,你从学习数据提供者的角色开始,数据提供者本质上是几个抽象基类(在System.Data.Common名称空间中)和接口类型(在System.Data名称空间中)的具体实现。您还看到了使用 ADO.NET 数据提供者工厂模型构建提供者中立的代码库是可能的。
您还了解了如何使用连接对象、事务对象、命令对象和数据读取器对象来选择、更新、插入和删除记录。此外,还记得 command 对象支持内部参数集合,您可以使用它为 SQL 查询增加一些类型安全性;事实证明,这些在触发存储过程时也非常有用。
接下来,您学习了如何用事务保护您的数据操作代码,并通过使用 ADO.NET 使用SqlBulkCopy类将大量数据加载到 SQL Server 来结束这一章。