连续数据库集成






4.80/5 (29投票s)
使用NAnt和CruiseControl.NET持续集成数据库更改
引言
以下文章是在阅读了Martin Fowler和Pramod Sadalage的《进化式数据库设计》后写成的。这些概念很棒,但我找不到任何真正展示如何实施它的网络资源。我非常喜欢测试数据库迁移的想法,所以我决定自己从头开始做。以下是我在当前工作中实现它的方法。进化式数据库设计
环境
开发者的机器
- SQL Server实例
- 开发
- 部署
- osql.exe (或isql.exe)
- Nant 0.85
- 某种形式的源代码控制(我使用Perforce)
构建服务器
- SQL Server实例
- 开发
- 部署(目前我负担不起单独的暂存服务器)
- osql.exe (或isql.exe)
- Nant 0.85
- CruiseControl.NET
- 某种形式的源代码控制(我使用Perforce)
生产服务器 (ProdServer)
- SQL Server实例
- 生产
文件夹/项目结构
- CreateScripts
- 数据库
- 表格
- 视图
- 约束
- 触发器
- StoredProcedures
- 函数
- MigrationScripts
要求
我们当前的项目需要定期发布,而这些发布通常涉及数据库更改。我们使用Perforce作为源代码控制系统。CruiseControl.NET运行在我们的构建服务器上,并配置为在每次代码签入时触发NAnt构建脚本。签入时的构建要求是……
- 创建一个空的数据库用于单元测试。在上面运行所有单元测试。
- 测试新生产数据库的部署或现有生产数据库的升级。
实现
设置
如环境的文件夹部分所示,项目文件夹必须按照上述方式设置。但是,在此结构中需要强制执行一些规则。
CreateScripts\Databases 和 CreateScripts\Tables 文件夹对开发人员应该是只读的。这是因为创建数据库或创建表需要这些数据库对象不存在。如果我们有一个现有数据库,然后对其执行CREATE DATABASE
,或者有一个现有表然后对其执行CREATE TABLE
,脚本将失败(或者更糟,如果我们包含IF EXISTS... DROP TABLE
)。因此,这些脚本应该放在迁移脚本中。对现有表的任何更改(ALTER TABLE
)或数据库(ALTER DATABASE
)也属于MigrationScripts文件夹。
其余文件夹可以愉快地用于存储对象。销毁对象并重新创建它们不会影响底层数据。
最后要注意的一点是,CreateScripts\Constraints文件夹应只包含引用完整性约束。这些约束不应包含在放在MigrationScripts文件夹中的CREATE TABLE
脚本中。
驱动脚本
我们首先构建CruiseControl.NET用于启动构建的整体应用程序.build脚本。
<?xml version="1.0" encoding="UTF-8" ?>
<project name="Application" basedir="." default="checkinBuild">
<!-- Target runs on every checkin -->
<target name="checkinBuild">
<!-- build the unit test database first -->
<property name="database.server" value="(local)"/>
<property name="database.instance" value="DEV"/>
<nant buildfile="Database.build" inheritall="true"
target="build" failonerror="true"/>
<!-- And now, run the deployment -->
<property name="database.server" value="(local)"/>
<property name="database.instance" value="DEPLOY"/>
<nant buildfile="Database.build" inheritall="true"
target="restoreDatabase"/>
<nant buildfile="Database.build" inheritall="true"
target="deploy" failonerror="true"/>
</target>
</project>
默认目标是checkinBuild,表示此NAnt目标将在每次代码签入时运行。首先,我们调用database.build文件的build目标。通过在调用具有inheritall=true
的目标之前设置database.server
和database.instance
属性来确定构建目标。第二个目标 - deploy - 工作方式相同,只是它执行新构建或升级现有构建。另请注意,我们首先恢复数据库。这是因为我们不希望在当前数据库上运行脚本(它可能已经运行了迁移脚本)。相反,我们希望恢复最新的生产数据库备份。这样,我们就在一个真实的系统上进行测试。
新数据库脚本
新数据库脚本在database.build NAnt脚本中创建。此目标的唯一目的是完全销毁现有数据库,并用一个全新的数据库覆盖它,从头开始创建。结果满足要求1。一个空的数据库,准备进行单元测试。
<target name="build" description="Create a brand new database">
<call target="createDatabase"/>
<call target="createTables"/>
<call target="createViews"/>
<call target="migrate"/>
<call target="createConstraints"/>
<call target="createProcedures"/>
<call target="createFunctions"/>
</target>
关于上述脚本的关键点是,我们在createDatabase和createTables目标之后调用migrate目标(稍后描述)。我们这样做的原因是,在创建数据库和表之后,迁移脚本可能包括对表的修改、新表或从现有表中删除列。所有这些都需要包含在内才能完成数据库结构。
深入研究每个createXxxx任务,它们看起来是这样的。
<target name="createTables">
<foreach item="File" in "CreateScripts\Tables"
property="script" failonerror="false">
<call target="osql" failonerror="true"/>
</foreach>
</target>
<target name="createProcedures">
<foreach item="File" in "CreateScripts\Tables"
property="script" failonerror="false">
<call target="osql" failonerror="true"/>
</foreach>
</target>
这些都遍历了相应文件夹中的所有脚本,并使用osql在数据库上运行它们。(底部有一个osql任务对此进行了说明)
* 聪明的读者可能会注意到,我们正在针对Databases和Tables文件夹运行脚本,而我提到这些对开发人员应该是只读的。简而言之,这些文件夹将在迭代结束时自动填充。我将在下面进一步解释。
运行构建目标并调用所有创建脚本的结果是,我们拥有一个由开发人员输入的脚本创建的全新空白数据库。
部署数据库脚本
第二个要求是,我们能够测试数据库到现有服务器的部署。这里有两个要求。首先,如果数据库尚不存在,则需要创建一个全新的数据库。但是,如果它确实存在,我们只需要升级。我们使用osql通过database.name
属性连接到数据库。我们假设如果osql失败,则数据库不存在,因此我们需要从头开始创建它。如果成功,我们可以安全地假定该名称的数据库已存在,我们需要对其进行升级。选择脚本看起来像这样……
<target name="deploy">
<!-- Attempt to connect to the named database.
If the database exists, osql -b will return 0 -->
<exec program="osql" commandline="-b -E -s
${database.connection} -d ${database.name} -Q "GO"
resultproperty="osql.result" failonerror="false"/>
<if test="${osql.result == '0'}">
<call target="upgrade"/>
</if>
<if test="${osql.result == '1'}">
<call target="build"/>
</if>
</target>
升级脚本与新脚本类似,但不包括创建数据库和创建表脚本。(否则我们可能会删除并重新创建它们,丢失数据)
<target name="upgrade"
description="Upgrades an existing eventlive database">
<call target="backupDatabase" failonerror="true"/>
<property name="nant.onfailure" value="restoreDatabase"/>
<call target="migrate" failonerror="true"/>
<call target="createConstraints" failonerror="true"/>
<call target="createProcedures" failonerror="true"/>
<call target="createFunctions" failonerror="true"/>
</target>
那些弯弯绕的部分
这就是事情开始变得有点复杂的地方。在将数据迁移或升级数据库作为一系列任务时,任务的执行顺序至关重要。例如,第一个任务可能添加一个类别表和 Suppliers 表的一个列。它还添加了供应商和类别之间的引用完整性约束。这里的顺序很重要。首先,我们不能在表和列都添加之前创建约束。这就是为什么迁移在约束之前运行。如果稍后的数据迁移任务希望向表中添加新类别,我们需要确保先运行创建表脚本。在这种情况下,MigrationScripts文件夹内部的顺序很重要。
有几种方法可以做到这一点。
- 手动
- DateCreated
- DataModified
我通过执行dir /b /od /tc *.sql > order.txt
来使用创建日期,然后遍历生成的文本文件并按该顺序运行脚本。或者,您可以删除我执行的dir,并以任何您喜欢的方式生成order.txt。migrate目标虽然与createXxx目标类似,但它并不遍历目录,而是读取order.txt文件。
<target name="migrate"
description="Run the migration scripts on the database">
<exec program="${solution.dir}\tools\listFiles.bat"
commandline="*.sql order.txt"
workingdir="Migration Scripts" failonerror="false"/>
<foreach item="Line" in="Migration Scripts\order.txt"
property="script">
<property name="script" value="Migration Scripts\${script}"/>
<call target="osql" failonerror="true"/>
</foreach>
</target>
到目前为止的总结……
上述脚本会在每次迭代中的每次签入时调用。每次影响数据模型的代码更改、每次数据迁移、数据插入或清理都始终放入MigrationScripts目录。每次签入时,NAnt构建都会从头开始重建数据库。它还会获取生产数据库的最新备份,并将其还原到DEPLOY实例 - 最后在生产数据库上运行deploy目标以测试部署。
迭代结束
到目前为止,一切都很顺利,签入一直在重建数据库,并在暂存数据库上测试迁移脚本的执行。一切按预期工作,每个人都很满意。现在我们只需要完成迭代。首先,我们部署到生产服务器。我们知道这有效,因为我们通过从生产环境恢复并测试脚本在最后一个构建脚本上进行了测试。部署很简单……
Nant.exe -buildfile:ApplicationDir\Database.build deploy -D:database.server=ProdServer -D:database.instance=LIVE
我们需要做的最后一件事是在迭代结束后进行清理。这包括归档迁移脚本 - 我们不想再次运行它们。但问题是,如果我们归档了包含创建表脚本的迁移脚本,我们就需要能够再次运行它们来创建下一个迭代的新数据库。所以我们改用升级后的数据库(在DEPLOY上)来自动生成创建数据库和创建表脚本。这些脚本然后被放置在CreateScripts\Databases和CreateScripts\Tables文件夹中 - **由构建服务器而不是开发人员**。一旦生成了这些脚本,我们就可以将MigrationScript项移动到存档文件夹。NAnt目标如下所示。
<!-- Script database takes an existing cr
eated database and scripts the tables and database -->
<!-- creation routine for later checking back in to
source control -->
<!-- Note that this should ONLY be called when
delivering on an iteration -->
<target name="scriptDatabase">
<exec program="cscript.exe" verbose="true">
<arg line="${solution.dir}/Tools/scriptDatabase.wsf //Nologo //B"/>
<arg value="//job:database"/>
<arg value="${database.connection}"/>
<arg value="${database.name}"/>
<arg value="Create Scripts/Databases"/>
</exec>
<exec program="cscript.exe" verbose="true">
<arg line="${solution.dir}/Tools/scriptDatabase.wsf //Nologo //B"/>
<arg value="//job:tables"/>
<arg value="${database.connection}"/>
<arg value="${database.name}"/>
<arg value="Create Scripts/Tables"/>
</exec>
</target>
<target name="archiveScripts" description="Archive the database scripts"
depends="scriptDatabase">
<!-- Now we need to archive the migration scripts -->
<tstamp property="archive.dir" pattern="yyyyMMdd"/>
<move todir="Archive\${archive.dir}">
<fileset basedir="Migration Scripts">
<include name="*"/>
</fileset>
</move>
</target>
注意 - 我们正在调用一个脚本宿主程序,该程序通过SQLDMO生成表和数据库脚本 - 简而言之……
var objArgs, serverName, databaseName
objArgs = WScript.Arguments
if(objArgs.Length != 3)
{
WScript.Arguments.ShowUsage();
WScript.Quit(1);
}
serverName = objArgs(0);
databaseName = objArgs(1);
outputPath = objArgs(2);
var fso = new ActiveXObject("Scripting.FileSystemObject");
if(!fso.FolderExists(outputPath))
{
WScript.Echo("Folder doesn't exist");
WScript.Quit(1);
}
var sqlServer, sqlDB, tableCollection, table, e;
server = new ActiveXObject("SQLDMO.SQLServer");
server.LoginSecure = true;
server.Connect(serverName, null, null);
database = new ActiveXObject("SQLDMO.Database");
database = server.Databases(databaseName);
/* DMO Constants */
var SQLDMOScript_Drops = 1;
var SQLDMOScript_IncludeIfNotExists = 4096;
var SQLDMOScript_IncludeHeaders = 131072;
var SQLDMOScript_Default = 4;
var SQLDMOScript2_NoCollation = 8388608;
var SQLDMOScript_DRI_PrimaryKey = 268435456;
var SQLDMOScript_DRI_Checks = 16777216;
var SQLDMOScript_DRI_Defaults = 33554432;
var SQLDMOScript_DRI_UniqueKeys = 67108864;
var SQLDMOScript_ToFileOnly = 64; lang=jscript>
tableCollection = new Enumerator(database.Tables);
for( ; !tableCollection.atEnd() ; tableCollection.moveNext())
{
table = tableCollection.item();
if(!table.SystemObject)
{
var params = SQLDMOScript_Drops |
SQLDMOScript_IncludeIfNotExists |
SQLDMOScript_IncludeHeaders |
SQLDMOScript_Default |
SQLDMOScript_DRI_PrimaryKey |
SQLDMOScript_DRI_Checks |
SQLDMOScript_DRI_Defaults |
SQLDMOScript_DRI_UniqueKeys |
SQLDMOScript_ToFileOnly;
table.Script(params, fso.BuildPath(outputPath,
table.Name + ".sql"),
null, SQLDMOScript2_NoCollation);
WScript.Echo("Processing " + table.Name);
}
}
server.DisConnect();
我们在各自的文件夹中有一套完整的CreateScripts,调用了archive目标,它会创建一个以日期命名的目录。然后将MigrationScript文件移动到此目录。(稍后会签入源代码控制)迭代结束。
结论
实现起来并不难。但付出的努力带来的好处是巨大的。在我之前工作过的公司里,脚本的运行一直由DBA负责,并且需要花费一周的时间让大家聚在一起回滚失败的脚本,更新那些依赖于失败脚本的脚本,以及通常只是确保它能正常工作。这容易出现转录错误、权限错误、脚本错误和不一致。通过每次测试部署以及单元测试,我们可以减少这种情况。最后值得注意的一点是,仅仅因为更改更容易,并不意味着开发人员可以随意修改表。数据库设计仍然需要仔细考虑。
注释
- 我已经忽略了backupDatabase和restoreDatabase目标 - 这些留给读者作为令人头疼的练习。
- 我使用了.wsf文件来编写表和数据库的创建脚本,因为它的速度足够快,而且对我来说很有效。如果速度是问题,可以使用vb、C#、C++、java或任何你想要的其他语言。
- 我假设生产数据库备份可以传输到构建服务器。在某些环境中,生产数据是敏感的或受到隐私法的约束。在这种情况下,可以使用测试数据库。但是,重要的是它应该与前一个迭代结束时的结构相同。
- 存储在MigrationScripts文件夹中的任何数据库重构脚本或数据清理/迁移脚本都应该在脚本的开始和结束时包含SQL来验证值,以确保它已正确迁移。失败应引发错误,强制构建失败。RAISEERROR可能是合适的。