传统数据库结构管理
单开发者模式:在日常开发过程中,经常会有修改数据库表结构的操作。常规操作是直接连接数据库(无论是通过命令行连接还是可视化工具连接例如Navicat、pgAdmin等),然后执行DDL语句对数据库进行修改。
多开发者模式:以上述方式管理数据库结构,在独立开发时没有问题,修改起来也最快最方便。但是若是要多人协作开发,采取这种方式开发,每次修改数据库后,都需要和其它开发者同步数据库结构。为了同步数据库结构,要么单独将修改时执行的语句发给其他开发者,要么导出完整的数据库结构构建sql重建其它开发者的数据库,执行起来非常麻烦。
多开发者单数据库模式:为了解决这种问题,有的团队会单独部署一台公共机器安装数据库,所有人直接连接和修改此数据库,避免了同步数据库结构的麻烦。然而这又可能引发新的麻烦,例如,某个开发者修改了公共数据库的某个字段,但还没来得及将自己修改后的数据库访问代码和其它开发者同步,其他开发者在运行时就会报错。
数据库模式版本管理方案
分析上述几种情况,可以看出,这几种问题的产生本质上都是因为数据库结构版本和代码版本脱节所导致的问题,那么自然而然的,是否可以将数据库结构像代码一样进行版本控制?是不是可以将数据库版本和代码版本统一管理,实现更新代码版本就更新数据库版本?
方案一、通过Git管理Sql脚本
既然我们已经在使用git对代码版本进行管理,每次对数据库执行操作也是通过Sql语句完成的,那么只要把修改数据库操作的Sql语句组织成为Sql文件脚本,将其纳入Git仓库进行版本管理就可以。具体做法是每次修改数据库后,把执行的Sql语句作为新的Sql文件添加进Git仓库即可,团队其它开发者更新代码版本后,即可同步获取到最新的Sql脚本。
这种方式优点时最符合单人开发时的行为,操作简单易懂,对数据库的操作更为直观,缺点是只能向前更新,无法向后回滚,团队其它开发者可能不会注意到有新的Sql脚本需要执行。
为了解决这种方案的缺点,还可要求更新数据库操作时同步编写相应的回滚Sql代码,将对数据库模式的变更改回到修改之前,但是对团队要求较高,手动编写Sql代码容易出错。
方案二、通过专业数据库模式版本管理工具管理
一些第三方工具可以实现完整的数据库模式版本管理,目前最流行的工具有Flyway、Liquibase等,他们均能够以集成的方式保存在项目代码中,例如Springboot集成。但这两款软件均有商业版和开源版两种形式,不同版本提供的功能也不尽相同。其中,Liquibase无论开源版还是商业版均提供集成、更新、回滚等基本功能,支持以多种形式(如sql、json、yaml、xml等)编写数据库模式变更文件,但回滚需要手动编写回滚脚本;而Flyway开源版本不支持回滚功能,且仅支持以sql形式编写数据库变更文件。
这种通过第三方数据库模式版本管理工具来实现对数据库版本管理的方式虽然功能强大,但都引入了新的管理系统,提高了管理成本,虽然也能集成在现有的项目工程中,但是也需要额外学习和编写相关的变更脚本,有一定的学习成本。
在不同语言支持上,Liquibase本身以jar包形式提供,对Java语言支持较好,提供了Maven和Gradle支持,此外社区支持了其它语言中的使用(基本都是通过命令行调用)例如.Net、Python、Ruby on Rails、Node.js等;Flyway也是类似的情况,为Java提供了Maven和Gradle支持,还提供了Springboot集成,同样也支持命令行调用,以此来支持在其它语言环境下的使用。
综合来看,通过第三方数据库模式版本管理工具对数据库模式进行管理,往往需要额外的一套管理体系,需要学习额外的概念,并且也往往没有自动生成更新或回滚的变更代码或脚本,虽然能和Springboot集成,但其不算完全融入工程中,相对还是较为独立的一部分,此外流行的这两个工具均只对Java有第一方支持,对其它语言不够友好。
方案三、ORM提供的数据库迁移工具
ORM(Object-Relational Mapping,对象关系映射)提供了数据库关系与面向对象设计之间的映射,开发者通过使用ORM能够以对象的形式来处理数据库中的数据。具体来说,在数据库中的关系模式是以表形式和表之间的关联构成的,这与面向对象设计中的对象和对象引用非常类似,因而可以将数据库的关系模式和面向对象进行映射,从而使开发者可以通过操作对象的方式对数据库进行增删查改。常用的ORM有
Java:Hibernate
Python:Django ORM、sqlalchemy
PHP:Eloquent ORM、Doctrine
Go:GORM、XORM
.Net:EntityFramework Core、SqlSugar、FreeSql
…
上述这些ORM(除Hibernate外),均提供一种名为迁移(Migration)的工具。迁移的基本思想是将一系列对数据库的DDL操作组织为一次迁移,以某种形式(代码或配置文件等)保存,再由开发者手动或自动执行迁移工具提供的数据库更新操作,连接数据库后根据指令和数据库模式版本生成并运行实际的DDL语句从而实现修改数据库模式。
对数据库执行一次迁移就变更了一次版本,数据库的最新版本就是由一次次的迁移累计,从最初的空数据库版本演化到最终符合应用程序需求的版本。基于ORM的迁移工具往往都是由工具自动扫描代码或数据库生成相应的迁移文件,因而也都能够做到回滚代码的生成,从而实现数据库模式版本的回滚。
这种方式基于ORM实现,本身与工程代码结合紧密,可以做到随代码版本管理一致的效果,还能够在应用启动的时候自动执行迁移,能够有效解决数据库版本管理和代码版本管理不统一的问题。此外,这些ORM也都全量支持数据库的回滚,没有对功能进行限制。但是这种方式也是有一定缺陷的,例如对某些数据库的特定特性支持不够好、生成的迁移文件有时候不会完全满足实际需求仍需要人工修改迁移文件等。
最佳解决方案——基于ORM的数据库迁移
目前来看,基于ORM的数据库迁移是对数据库模式版本管理的最佳选择。这种模式往往分为两大形式:Code First和DB First。
DB First
DB First的基本思想是通过扫描现有的数据库模式,推断并生成相应的数据库实体类和相关的配置,并创建迁移文件。开发者需要先在数据库中创建好需要的表,配置完成后运行迁移工具,由迁移工具自动扫描创建好的数据库,再生成相应的数据库实体以及相关的数据库配置。
这种形式往往适用于单人开发,或仅在项目初期使用,或是对数据库没有很高要求的项目,或需求变更不多的项目。这是由于每次扫描完数据库后生成的实体类都会覆盖此前的生成数据库实体类(当然也许某些工具实现了自动合并而非完全覆盖,这里仅指大部分情况),若在扫描完成后对数据库实体进行了一些配置,例如给实体类添加方法、给实体类属性添加注解或特性等操作,则再次运行迁移工具扫描生成代码后,所有修改过的代码均会丢失(虽然可以通过一些其它工具或方法恢复,例如git或提前复制出一份修改后的代码,但非常麻烦)。
DB First不需要手动编写实体类代码,它非常符合学习数据库时的习惯,先分析好整个应用所需的数据库模式,然后直接在数据库中创建好对应模式,最终仅需调用一下迁移工具,就能自动生成所有实体类,完成数据库对象映射,并完全与实际数据库结构一致。缺点是灵活性不够高,通常无法对实体类进行修改,否则会面临被覆盖的可能。
Code First
Code First和DB First完全相反,它不是扫描数据库生成代码,而是扫描数据库实体类代码生成数据库迁移文件,由开发者运行迁移文件后修改数据库模式。Code First没有实体类可能会被覆盖的风险(除非某位开发者强制执行了DB First扫描操作),开发者可以完全掌控实体类的实现细节。
ORM往往通过提供额外的特性或注解,或是特定的设置方法,来实现Code First模式下对最终生成的数据库模式精细化操控的实现。例如,User类拥有一个name属性,该属性类型为string,但在数据库中,字符串类型往往有几种不同的类型,比如在PostgreSql中,就有text、varchar、char等,迁移工具往往会将string类型默认映射到某一个类型中,若想更加精准地指定最终生成的数据库类型,就可通过ORM提供的这些特性或注解或设置方法实现。例如,以下是C#的EntityFramework Core框架(后称EF Core)中一个数据库实体类的实现,它精准地设定了Url的实际数据库列类型:
public class Blog
{
public int BlogId { get; set; }
[Column(TypeName = "varchar(200)")]
public string Url { get; set; }
[Column(TypeName = "decimal(5, 2)")]
public decimal Rating { get; set; }
}
但通过这种方式有时候也不太能覆盖到所有的情况,偶尔还是需要开发者手动修改或编写迁移文件,幸好这些ORM大多提供了完善的支持,能够让开发者直接理解或编写迁移文件,例如,以下是一个PHP框架Laravel的迁移文件示例:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 运行迁移程序
*
* @return void
*/
public function up()
{
Schema::create('flights', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('airline');
$table->timestamps();
});
}
/**
* 回滚迁移
*
* @return void
*/
public function down()
{
Schema::drop('flights');
}
};
该迁移文件可通过手动编写或由工具自动生成产生,可以发现迁移文件内部实际上也都是一些可理解的方法调用,因此由开发者编写或修改完全是可行的。
这些迁移工具是如何在Code First模式下,实现自动生成迁移代码的呢?
以EF Core为例,它会自动建立一个snapshot文件,里边存放了当前数据库模式版本中所有表、索引等的相关信息。当执行迁移工具扫描命令时,它会扫描数据库实体类和相关的数据库设置方法调用,根据代码信息,在内存中构建出执行命令时数据库实体类所构建出的完整数据库模式,然后去和snapshot文件中保存的数据库模式信息对比,查找出修改内容,依据修改内容生成一个迁移文件,该迁移文件中就保存了此次修改的具体数据库操作方法调用。
项目一开始,数据库为空,snapshot文件内容也为空,此时执行扫描命令,就是全量的修改。当后续对数据库实体类进行修改后,再次执行扫描命令,重复上述过程,就能够查找出部分修改的内容,从而生成相应的迁移文件,对数据库做增量修改,而非全量覆盖。
这些迁移工具也会自行在数据库中创建迁移执行历史表,用于存放迁移执行历史。当使用迁移工具对数据库应用迁移时,迁移工具就会自动读取迁移执行历史表内容,查找数据库此前执行过哪些迁移,计算出本次需要执行哪些迁移(或回滚哪些迁移)。
以上的机制就实现了完整的数据库模式版本管理,可以看到这种方式优势在于完全与工程代码结合,可以做到增量修改,能够根据代码自动生成迁移文件,也有着较大的灵活性。缺点则在于工具自动生成的迁移文件有时候不能完全符合实际需求,需要手工添加辅助信息或修改生成的迁移文件,要求约束开发者必须经由迁移的形式修改数据库模式。
总结
实现数据库模式版本管理有多种方式,需要根据实际开发模式和需求进行选择。但是综合来看,个人仍然推荐使用Code First方式进行数据库模式版本管理。由于不同语言、不同框架的ORM实现方式差异很大,本文没有对具体的使用方式进行过多阐述,仅对其实现思想进行了描述,不同的ORM之间差异也很大,需要结合实际项目需求分析选择。