DBTool - 数据库架构版本控制辅助工具和 TDD 简介






4.22/5 (5投票s)
本文介绍了使用 TDD 方法进行数据库模式更新的自研工具的设计、开发和测试。
引言
数据库更新从来都不是一件容易的事。在开发过程中调整模式没什么问题,但如果没有付出努力,你无法在生产环境中获得最新最好的东西。然后,并发开发就来了:当你拥有最新最好的东西时,你的同事可能还在使用旧的模式,或者他们自己也有更改,而你绝不想删除他们的更改。为了寻找一种更好的方法来管理数据库模式更改,我偶然发现了 K. Scott Allen 的一个精彩系列博客(通过)。
简单来说,这个想法是有一个基线数据库模式,并在模式更改时应用一系列的增量更新。所有模式更改都通过同一数据库中的一个特殊表进行跟踪,因此任何更新都不会被应用两次或被遗漏。基线模式和更新都可以轻松存储在您选择的版本控制系统中。对我来说,这听起来很完美,除了……没有这样的工具。
本文介绍了使用 TDD 方法进行数据库模式更新的自研工具的设计、开发和测试。本文的初衷之一是展示 TDD 如何显著加快软件子系统背后复杂逻辑的开发和调试速度。
功能要求
为了自动化整个数据库模式版本控制任务,我们需要一个工具。该工具必须能够执行批量更新(一次执行一组 SQL 命令)和模式更新(知道更新历史并可应用所有必需的更新以将模式带到当前状态)。因此,工具必须理解的示例命令行列表涵盖了上述场景。
1. dbtool.exe /update:batch [/db:database] [/user:username] [/notransaction] [[/swallow:]script.ddl...] 2. dbtool.exe /update:schema /db:database [/user:username] /schema:schema.ddl 3. dbtool.exe /update:list /db:database [/user:username]
第一个命令将逐一执行命令行中列出的脚本,可选地吞噬执行过程中的任何异常。第二个命令将安装和更新模式。第三个命令将列出所有已安装的模式更新。
设计
鉴于功能需求,有三种不同的算法应用于同一个主题——数据库。我认为这里使用策略模式是个好主意:客户端(dbtool.exe)将使用 /update 开关作为算法实现的选择器,以配置算法并执行它。
显然,开发像我们的 dbtool 这样通用的工具是一个复杂的过程,并且可能在许多地方出错。我们最不想做的就是在调试器会话中尝试捕获尽可能多的 bug。相反,我们将使用 TDD,并请潜在的 bug 来“参加派对”。将 TDD 引入其中意味着我们很可能需要模拟某些子系统来隔离我们正在测试的功能,因此使用依赖注入模式以实现这种功能是一个好主意。我们将使用xUnit.net 框架作为我们的单元测试平台。
由于客户端及其测试共享相同的组件,因此我们最好使用抽象工厂模式来尽可能实例化和配置具体对象。
为了简化我们的工具发布和在生产环境中使用的故障排除过程,我们将使用log4net 框架提供的日志记录功能。
实现
功能需求文档清楚地描述了 dbtool 的工作流程。
- 用户启动 dbtool.exe 并带有一组命令行参数。
- Dbtool 解析命令行并选择要使用的算法。
- Dbtool 使用用户通过命令行设置的参数执行选定的算法。
如何执行数据库模式更新
Dbtool 开箱即用地支持批量数据库更新,无需额外要求。但是,为了启用模式更新功能,您的数据库必须有一个特殊表——schema_information——该表将存储已安装更新的信息。
CREATE TABLE schema_information (
id INT IDENTITY(1,1) NOT NULL,
major INT,
minor INT,
release INT,
script_name VARCHAR(255),
applied DATETIME,
CONSTRAINT PK_schema_information PRIMARY KEY (id)
)
GO
此外,您的基线模式脚本还必须包含一个额外的语句,该语句确定已安装模式的版本。
/*================================================================================*/
/* Set initial schema revision number. */
/*================================================================================*/
INSERT INTO [schema_information] ([major], [minor], [release], [script_name], [applied])
VALUES (1, 0, 0, 'baseline', GETDATE())
GO
最后的要求是用于更新脚本。所有升级模式版本的更新都必须位于模式所在文件夹下的 Updates 文件夹中,并且每个更新文件名必须采用 update-xxx.yyy.zzz.ddl 格式。
C:\temp\SchemaTest\Schema.ddl
C:\temp\SchemaTest\Updates\update-001.001.002.ddl
C:\temp\SchemaTest\Updates\update-001.001.001.ddl
C:\temp\SchemaTest\Updates\update-001.000.001.ddl
在模式更新过程中,dbtool 将查询 schema_information 表。如果没有任何模式信息,将应用模式文件,然后再次查询版本号。然后 dbtool 将在模式文件所在文件夹下的 Updates 文件夹中查找更新脚本,并从文件名中提取版本信息。所有主版本号(xxx)小于模式主版本号的更新都将被拒绝。对于其余更新,将启动一个事务,然后逐一应用所有更新(例如,001.000.001,然后是 001.001.001,然后是 001.001.002,然后是 002.000.001)。执行完更新脚本后,dbtool 将更新 schema_information 表中的版本信息。一旦应用了最后一个更新,事务将提交。
对象模型
该工作流程基本上引入了 dbtool 的两个基石实体:配置(Configuration)和策略(Strategy)。配置会将命令行解析为对象模型,而策略将使用配置来执行用户请求的特定任务。
由于 dbtool 有三种操作模式,因此我们有三个类来保存配置数据,以及三个利用这些配置的策略。
配置
我们将有三个类来处理配置数据。
BatchUpdateConfiguration
– 保存批量数据库更新过程的配置。- 是否以事务模式执行更新(可选的 /notransaction 开关)。
- 要应用于数据库的更新列表。
SchemaUpdateConfiguration
– 保存数据库模式更新过程的配置。- 基线模式文件位置。
- 更新列表,位于基线模式位置的 updates 文件夹下。
UpdateListConfiguration
– 保存数据库模式更新列表过程的配置。
所有三个配置都有用户通过命令行提供的公共参数——数据库名称(/db 开关)和用户名(/user 开关)——以及在配置过程中填充的结构,例如脚本集合,它保存要执行的脚本列表,以及用户在工具提示时输入的数据库密码。此外,还有一个所有这三者共享的进程:命令行解析和验证。因此,为所有配置类(ConfigurationBase
)设置一个公共父类是有意义的,它将处理解析和验证过程,并处理公共配置过程。
要使用的配置类型取决于 /update 命令行开关。由于我们希望 dbtool 客户端和测试都能使用相同的配置,因此我们将配置对象实例化过程隔离在 ConfigurationFactory
类中。它的 CreateConfiguration()
方法接受一个命令行参数数组,并利用简单的逻辑实例化一个适当的配置对象。
测试
我们有四个参与者来处理配置过程。所有这四个都需要得到妥善测试,以确保我们涵盖各种命令行参数组合,包括有效和无效的。
需要注意的是,至少有两个配置类会与文件系统交互,以解析数据库更新脚本文件的路径。由于可能涉及绝对路径和相对路径以及各种嵌套级别,因此让单元测试直接与文件系统交互并不是一个好主意。鉴于我们的功能需求,所有文件操作都归结为定位文件和从文件中读取基本数据。通常你会使用 System.IO.Directory
和 System.IO.File
进行这些操作,但现在定义两个简单的接口——IFileListProvider
和 IFile
——是个好主意。IFileListProvider
将允许我们定位文件,而 IFile
将处理特定的文件操作。对于 dbtool 客户端,具体的实现将简单地将方法调用转发给相应的 System.IO.Directory
和 System.IO.File
方法,但对于我们的测试,我们将实现两个非常特殊的 FakeFileListProvider
和 FakeFile
类。它们将使用字典来指定客户端可以找到、列出或读取哪些文件。考虑这个片段:
fakeFileListProvider.Paths[@"C:\Temp\updates\*.ddl"] = new string[] {
@"C:\Temp\updates\two.ddl", @"C:\Temp\updates\four.ddl" };
基本上,我们指示我们的 fake list provider 实例在客户端提供特定文件掩码时返回两个文件名。
此片段指示 fake file 类返回无效 DB 脚本的内容。
fakeFile.Files["invalid.ddl"] = "adkfjlkajdf a;lksdjf\rgo aksdfasdjfaslkdjf asdl;asdf";
为了使用 IFileListProvider
和 IFile
,我们将修改 ConfigurationFactory
和 ConfigurationBase
的实现,以便它们具有特殊属性,这些属性将默认为适当的 FileListProvider
和 File 实例,但可以被我们的测试设置为 FakeFileListProvider
和 FakeFile
实例。
/// <summary>
/// Configuration object factory.
/// </summary>
public sealed class ConfigurationFactory
{
private IFileListProvider fileListProvider = new FileListProvider();
/// <summary>
/// Gets or sets file list provider object.
/// </summary>
public IFileListProvider FileListProvider
{
get { return fileListProvider; }
set { fileListProvider = value; }
}
}
/// <summary>
/// Base class for all configuration classes.
/// </summary>
public abstract class ConfigurationBase
{
private IFileListProvider fileListProvider = new FileListProvider();
/// <summary>
/// Reference to a file list provider implementation.
/// </summary>
public IFileListProvider FileListProvider
{
get { return fileListProvider; }
set { fileListProvider = value; }
}
}
这看起来可能有点多余,但将文件操作隔离后,我们在测试实现方式上拥有巨大的自由度:我们不必将文件系统维护在某种状态,可以想出绝对最奇怪的文件路径想法来证明我们的配置类已正确实现。
您将在 ArgumentTests
和 ConfigurationTests
类中找到我们配置子系统的测试。
策略
策略不过是一种算法,它接受配置作为输入,并使用配置数据执行特定操作。因此,我们所有策略需要做的就是实现一个具有单个方法——Execute()
——的接口。
Dbtool 将有三个不同的策略,实现在 BatchUpdateStrategy
(逐一执行脚本)、SchemaUpdateStrategy
(执行数据库模式更新)和 UpdateListStrategy
(列出所有已安装的模式更新)类中。
同样,由于 dbtool 客户端和测试都在处理策略,因此我们将使用 StrategyFactory
类,该类将确定实例化哪个策略类(通过使用配置类型作为标准),创建策略类的实例并对其进行配置,因此客户端所要做的就是调用 Execute()
方法。
测试
Dbtool 旨在与数据库协同工作,并且在此过程中涉及大量复杂的逻辑。测试核心功能以消除任何进一步的意外非常重要。基本上,该工具必须在适当的时间调用适当的脚本,处理成功场景和失败场景。在真实数据库上测试所有这些将非常痛苦。引入一个新实体——数据库管理器(database manager)——要容易得多,它将负责 dbtool 客户端中的所有数据库操作,并且仍然可以在测试中访问策略操作的结果。这就引出了一个新的接口——IDatabaseManager
——该接口将定义与数据库的五个关键操作:DatabaseExists()
、GetSchemaRevision()
、ListSchemaRevisions()
、ExecuteScript()
和 UpdateSchema()
。
这五个操作涵盖了我们计划对数据库进行的所有操作。Dbtool 客户端将实现一个真实的 SqlDatabaseManager
类,该类将使用 SqlDataAdapter
和 SqlCommand
来执行 IDatabaseManager
操作。我们的测试套件将实现一个 FakeDatabaseManager
类,该类将为测试提供几个附加属性,允许测试模拟各种成功和失败的场景。考虑以下片段:
[Fact]
public void StrategyPerformsUpdatesOnlyAndCompletesTransaction()
{
using (TransactionScope scope = new TransactionScope())
{
IStrategy strategy = CreateDefaultStrategy();
strategy.Execute();
scope.Complete();
}
Assert.Equal(4, databaseManager.ExecutedScripts.Length);
// Verify scripts order.
int i = 0;
foreach (string update in databaseManager.ExecutedScripts)
Assert.Equal(FileSystem.GetUnderUpdatesOf(defaultSchemaFile,
updateScriptsInOrder[i++]), update);
Assert.Equal(new SchemaRevision(1, 1, 1),
databaseManager.GetSchemaRevision(DatabaseName));
}
尝试访问策略的内部来了解它执行了哪些操作并验证是否以预期方式完成,这是没有意义的。衡量操作结果比这更有成效。在上面的例子中,FakeDatabaseManager
记录了策略“执行”的所有脚本(加引号,因为没有实际工作完成),我们可以轻松地检查它们的顺序。
以下片段演示了失败场景测试。
[Fact]
public void IfUpdateFailsThrowsAndAbortsTransaction()
{
databaseManager.Revision = null;
databaseManager.FailScriptIndex = 2;
Assert.Throws<TransactionAbortedException>(delegate()
{
using (TransactionScope scope = new TransactionScope())
{
// We don't know type of exception thrown, so catch everything
// and try to complete the transaction to trigger upper-level assert.
try
{
IStrategy strategy = CreateDefaultStrategy();
strategy.Execute();
Assert.False(true, "Wrong way: strategy should've thrown an exception.");
}
catch
{
// Trigger upper-level assert.
scope.Complete();
}
}
});
Assert.Equal(2, databaseManager.ExecutedScripts.Length);
Assert.Equal(defaultSchemaFile, databaseManager.ExecutedScripts[0]);
Assert.Equal(FileSystem.GetUnderUpdatesOf(defaultSchemaFile,
updateScriptsInOrder[0]), databaseManager.ExecutedScripts[1]);
}
在这里,我们指示 FakeDatabaseManager
实例使更新管道中的第三个脚本失败,然后验证事务是否被取消,因此实际上没有更新会被记录在数据库中,因此数据库状态将保持不变。
为了适应 IDatabaseManager
,我们的 StrategyFactory
会配置一个具体的数据库管理器类实例。Dbtool 使用 SqlDatabaseManager
,所有测试都使用 FakeDatabaseManager
实例。
您将在 BatchUpdateTest
、SchemaUpdateTest
和 UpdateListTests
类中找到策略测试。
整合所有内容
如果您想知道在创建了如此大量的“额外”类之后 dbtool 看起来是什么样子,请考虑以下片段(已省略诊断和错误处理代码)。
// Configure tool.
ConfigurationBase configuration = (new ConfigurationFactory()).CreateConfiguration(args);
// Create concrete database manager instance.
using (SqlDatabaseManager databaseManager = new SqlDatabaseManager())
{
// Configure data access password.
Console.Write("Enter {0}'s password > ", configuration.UserName);
configuration.Password = Console.ReadLine().Trim();
Console.WriteLine();
ConfigureScriptMacros(databaseManager);
// Configure and execute update strategy.
IStrategy strategy = (new StrategyFactory()).CreateStrategy(configuration,
databaseManager);
strategy.Execute();
}
就这样。
集成测试
使用 fake 对象开发和测试 dbtool 是一件很有趣的事情,但我们的工具将在真实世界中与真实文件和真实数据库一起工作。这时集成测试就派上用场了。基本上,集成测试过程归结为编写一组脚本,这些脚本将由 dbtool 执行并涵盖我们能想到的所有可能场景(成功和失败)。最棒的是,我们已经测试了配置处理和策略背后的所有逻辑,所以我们只需要确保我们的真实 SqlDatabaseManager
按我们预期的方式工作,并且我们的 dbtool 客户端能够完成它的工作。
集成测试还将帮助我们验证是否已准备好应对真实的数据库行为。例如,批量更新场景的命令行参数 /notransactions 在我运行了一个创建数据库的批量更新之前并不存在——你看,数据库创建过程不能在事务中进行。仅凭测试 fake 对象,我无法捕捉到这一点。
我们可以通过创建另一个使用真实 SqlDatabaseManager
和文件/文件列表提供程序的套件来执行集成测试,然后以编程方式进行检查,但我认为对于这个工具来说,这有点过度了,所以我最终创建了十几个脚本,这些脚本创建数据库,以批量和模式更新模式应用更新,列出更新,尝试执行一些无效操作等等,通过逐一运行它们,我只是观察行为并在每次运行后手动检查数据库结构。由于大多数逻辑都已通过 fake 对象进行了验证,因此这并不是一个很大的负担。
您将在测试套件的 Integration 文件夹下找到集成测试脚本。还有一个 Schema.jpg 文件,其中描绘了一个测试数据库模式。
感谢您的关注!
希望您能在日常工作中找到该工具和文章的用途。