跳转至

第27章:数据库/API的版本管理和迁移

在本书的前三节中,每当你对你的模型进行修改时,你必须删除你的数据库并重新开始。当你没有任何数据的时候,这并不是问题。一旦你有了数据,或者将你的项目转移到生产阶段,你就不能再删除你的数据库。你要做的是修改你的数据库,在Vapor中,这是用migrations完成的。

在本章中,你将使用迁移对TILApp进行两次修改。首先,你将为User添加一个新的字段,以包含一个Twitter账号。其次,你将确保类别是唯一的。最后,你将修改应用程序,使其仅在应用程序运行于开发或测试模式时创建管理员用户。

Note

本章的启动项目是基于第21章末的TIL应用程序。该启动项目包含额外的代码,所以你应该使用本章的启动项目。这个项目依赖于一个在本地运行的PostgreSQL数据库。

迁移是如何进行的

Fluent第一次运行时,它会在数据库中创建一个特殊的表。Fluent使用这个表来跟踪所有已经运行过的迁移,它按照你添加迁移的顺序来运行这些迁移。当你的应用程序启动时,Fluent会检查要运行的迁移列表。如果它已经运行了一个迁移,它就会继续运行下一个迁移。如果之前没有运行过这个迁移,Fluent就会执行它。

Fluent不会多次运行迁移。这样做会导致与数据库中的现有数据发生冲突。例如,设想你有一个迁移,为你的用户创建了一个表。在Fluent第一次运行迁移时,它创建了这个表。当它再次尝试运行时,一个具有该名称的表已经存在,从而导致了一个错误。

记住这一点很重要。如果你改变了一个现有的迁移,Fluent不会执行它。你需要重新设置你的数据库,就像你在前面的章节中做的那样。

修改表

修改一个现有的数据库总是一件有风险的事情。你已经有了你不想失去的数据,所以删除整个数据库不是一个可行的解决方案。同时,你不能简单地在一个现有的表中增加或删除一个属性,因为所有的数据都纠缠在一个大的连接和关系网中。

相反,你可以使用VaporMigration协议引入你的修改。这允许你谨慎地引入你的修改,同时还有一个恢复选项,如果他们不能像预期那样工作。

修改你的生产数据库总是一个微妙的过程。你必须确保在生产中推出任何修改之前对其进行适当的测试。如果你有很多重要的数据,在修改你的数据库之前做一个备份是个好主意。

为了保持你的代码整洁,并便于按时间顺序查看修改,每次迁移都应该有自己的文件。对于文件名,使用一致的、有帮助的命名方案,例如:YY-MM-DD-FriendlyName.swift。这样你就可以一目了然地看到你的数据库的各个版本。

编写迁移

当一个Migration被用来更新一个已有的模型时,它通常被写成一个struct。当然,这个struct必须符合Migration的要求。Migration要求你提供两样东西:

func prepare(on database: Database) -> EventLoopFuture<Void>

func revert(on database: Database) -> EventLoopFuture<Void>

Prepare方法

迁移需要一个数据库连接才能正常工作,因为它们必须能够查询MigrationLog模型。如果不能访问MigrationLog,迁移就会失败,在最坏的情况下会破坏你的应用程序。prepare(on:)包含了迁移对数据库的改变。它通常是两个选项之一。

  • 创建一个新的表
  • 通过添加一个新的属性来修改一个现有的表。

下面是一个向数据库添加一个新模型的例子:

func prepare(on database: Database) -> EventLoopFuture<Void> {
  // 1
  database.schema("testUsers")
    // 2
    .id()
    .field("name", .string, .required)
    // 3
    .create()
}
  1. 你指定要运行迁移的模式--或表名。
  2. 你指定要对表进行的修改。你可以指定对约束、字段和外键的操作。这包括将字段标记为唯一。对于字段,你可以指定字段的名称、类型和任何约束。
  3. 你指定要执行的操作和要使用的模型。如果你要向数据库添加一个新的表,例如创建一个新的Model,你可以使用create()。如果你要向现有的Model类型添加一个字段,你可以使用update()。这个例子使用create()来创建一个新的模型,有idname两个字段。

还原方法

revert(on:)prepare(on:)的反面。它的工作是撤销prepare(on:)所做的一切。如果你在prepare(on:)中使用create(),你在revert(on:)中使用delete()。如果你使用update()来添加一个字段,你也可以在revert(on:)中使用deleteField(_:)来删除该字段。

下面是一个例子,与你之前看到的prepare(on:)配对:

func revert(on database: Database) -> EventLoopFuture<Void> {
  database.schema("testUsers").delete()
}

再次,你指定要恢复的模式和要执行的操作。由于你使用了create()来添加模型,你在这里使用delete()

这个方法在你用--revert选项启动你的应用程序时执行。

Note

Fluent将只删除前一批迁移的数据,以避免与旧数据产生冲突。当改变一个数据库时,包括删除之前添加的字段,你应该尝试"向前修复"。这意味着创建一个新的迁移,删除你在之前的迁移中添加的字段。

FieldKeys

Vapor 3中,Fluent为你推断出了大部分的表信息。这包括列的类型和列的名称。这对于像TIL应用这样的小型应用来说效果不错。然而,随着项目的发展,他们会做出越来越多的改变。删除字段和改变列的名称是很困难的,因为这些列不再与模型匹配。Fluent 4通过要求你提供字段和模式的名称,使迁移变得更加灵活。

然而,这意味着你最终会在整个应用程序中重复字符串,这种技术很容易出错。你可以定义你自己的FieldKey来解决这个问题。在Xcode中,打开CreateAcronym.swift,在文件的底部添加以下内容:

extension Acronym {
  // 1
  enum v20210114 {
    // 2
    static let schemaName = "acronyms"
    // 3
    static let id = FieldKey(stringLiteral: "id")
    static let short = FieldKey(stringLiteral: "short")
    static let long = FieldKey(stringLiteral: "long")
    static let userID = FieldKey(stringLiteral: "userID")
  }
}

以下是这个新代码的作用:

  1. Acronym的扩展中定义一个枚举。你用你创建扩展的日期来命名这个枚举。这使得人们很容易看到你何时定义了列,何时发生了变化。
  2. 为模式的名称定义一个静态属性。这在你将来改变表名时很有用。
  3. 为表中的每一列定义一个FieldKey。你在MigrationModel中使用它们。

接下来,将CreateAcronym的主体替换为以下内容:

func prepare(on database: Database) -> EventLoopFuture<Void> {
  database.schema(Acronym.v20210114.schemaName)
    .id()
    .field(Acronym.v20210114.short, .string, .required)
    .field(Acronym.v20210114.long, .string, .required)
    .field(
      Acronym.v20210114.userID, 
      .uuid, 
      .required, 
      .references(User.v20210113.schemaName, User.v20210113.id))
    .create()
}

func revert(on database: Database) -> EventLoopFuture<Void> {
  database.schema(Acronym.v20210114.schemaName).delete()
}

这将用之前定义的键来替换你的迁移中的所有字符串。对User的引用也使用了在启动项目中已经定义的User迁移中的键。

接下来,打开Acronym.swift并替换。

static let schema = "acronyms"

为以下内容:

static let schema = Acronym.v20210114.schemaName

接下来,用以下内容替换shortlonguser的属性和属性包装器:

@Field(key: Acronym.v20210114.short)
var short: String

@Field(key: Acronym.v20210114.long)
var long: String

@Parent(key: Acronym.v20210114.userID)
var user: User

这将用你在CreateAcronym.swift中定义的FieldKey替换属性封装器的键。

最后,打开CreateAcronymCategoryPivot.swift。替换:

.field(
  AcronymCategoryPivot.v20210113.acronymID,
  .uuid,
  .required,
  .references("acronyms", "id", onDelete: .cascade))

为以下内容:

.field(
  AcronymCategoryPivot.v20210113.acronymID, 
  .uuid, 
  .required, 
  .references(
    Acronym.v20210114.schemaName, 
    Acronym.v20210114.id, 
    onDelete: .cascade))

这就用你之前定义的FieldKeyschemaName替换了这些字符串。现在,你的迁移或模型中不再有字符串了 这为你的迁移提供了类型安全,并使改变和更新字段变得简单。

添加用户的Twitter手柄

为了演示现有数据库的迁移过程,你将添加对收集和存储用户的Twitter手柄的支持。在Xcode中,在Sources/App/Migrations创建一个名为21-01-14-AddTwitterToUser.swift的新文件。这个新文件将保存AddTwitterToUser迁移。

接下来,打开CreateUser.swift。在User的扩展中,在v20210113下面添加以下内容:

enum v20210114 {
  static let twitterURL = FieldKey(stringLiteral: "twitterURL")
}

这就为新属性添加了一个新的FieldKey。接下来,打开User.swift,在User下面的var acronyms: [Acronym]添加以下属性:

@OptionalField(key: User.v20210114.twitterURL)
var twitterURL: String?

这就在模型中添加了String?类型的属性。你把它声明为一个可选的字符串,因为你的现有用户没有这个属性,而未来的用户也不一定有Twitter账户。你用@OptionalField来注解这个属性,告诉Fluent这个属性是数据库中的一个可选字段。

最后,将初始化器替换为以下内容:

init(
  id: UUID? = nil,
  name: String,
  username: String,
  password: String,
  twitterURL: String? = nil
) {
  self.name = name
  self.username = username
  self.password = password
  self.twitterURL = twitterURL
}

这将在初始化器中添加twitterURL参数,如果没有提供,则提供一个默认的nil值。

创建迁移

打开21-01-14-AddTwitterToUser.swift,添加以下内容来创建一个迁移,将新的twitterURL字段添加到模型中:

import Fluent

// 1
struct AddTwitterURLToUser: Migration {
  // 2
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    // 3
    database.schema(User.v20210113.schemaName)
      // 4
      .field(User.v20210114.twitterURL, .string)
      // 5
      .update()
  }

  // 6
  func revert(on database: Database) -> EventLoopFuture<Void> {
    // 7
    database.schema(User.v20210113.schemaName)
      // 8
      .deleteField(User.v20210114.twitterURL)
      // 9
      .update()
  }
}

下面是这个的作用:

  1. 定义一个新的类型,AddTwitterURLToUser,它符合Migration
  2. 定义所需的`prepare(on:)'。
  3. 使用定义的模式选择`User'表。
  4. 使用之前定义的FieldKey,用field(_:_)添加新字段。设置类型为string
  5. 调用update()来执行迁移并更新表。
  6. 定义必要的revert(on:)
  7. 使用定义的模式选择User表。
  8. 删除先前由FieldKey定义的字段。
  9. 调用update()来执行迁移并删除字段。

现在打开configure.swift,将AddTwitterURLToUser注册为其中一个迁移。

由于Fluent是按顺序执行迁移的,所以它必须在列表中的现有迁移之后。然而,由于CreateAdminUser会创建一个新的用户,所以你必须在之前添加这个迁移。否则,当使用一个新的数据库时,CreateAdminUser会失败。在app.migrations.add(CreateAdminUser())之前添加以下内容:

app.migrations.add(AddTwitterURLToUser())

下次你启动应用程序时,Fluent会将新的属性添加到User中。构建并运行你的应用程序;你会在你的表中看到新的属性。

在你的开发机器上,你可以通过在终端输入以下内容来查看表的属性:

docker exec -it postgres psql -U vapor_username vapor_database
\d "users"
\q

API进行改版

你已经改变了模型以包括用户的Twitter帐号,但你没有改变现有的API。虽然你可以简单地更新API以包括Twitter的手柄,但这可能会破坏你的API的现有消费者。相反,你可以创建一个新的API版本来返回带有Twitter手柄的用户。

要做到这一点,首先打开User.swift,在Public后面添加以下定义:

final class PublicV2: Content {
  var id: UUID?
  var name: String
  var username: String
  var twitterURL: String?

  init(id: UUID?, 
       name: String, 
       username: String, 
       twitterURL: String? = nil) {
    self.id = id
    self.name = name
    self.username = username
    self.twitterURL = twitterURL
  }
}

这将创建一个新的PublicV2类,其中包括twitterURL。接下来,为版本2API创建四个转换方法。在convertToPublic()之后为Userextension添加以下内容:

func convertToPublicV2() -> User.PublicV2 {
  return User.PublicV2(
    id: id, 
    name: name, 
    username: username, 
    twitterURL: twitterURL)
}

现在,在convertToPublic()后的EventLoopFuture where Value: Userextension中添加以下内容:

func convertToPublicV2() -> EventLoopFuture<User.PublicV2> {
  return self.map { user in
    return user.convertToPublicV2()
  }
}

然后,在convertToPublic()之后为Collectionextension添加以下内容:

func convertToPublicV2() -> [User.PublicV2] {
  return self.map { $0.convertToPublicV2() }
}

最后,在convertToPublic()后的extension中为EventLoopFuture where Value == Array<User>添加以下内容:

func convertToPublicV2() -> EventLoopFuture<[User.PublicV2]> {
  return self.map { $0.convertToPublicV2() }
}

这允许你在所有你可能想要的实例中把你的Fluent模型转换为PublicV2。打开UsersController.swift,在getHandler(_:)后添加以下内容:

// 1
func getV2Handler(_ req: Request) 
    -> EventLoopFuture<User.PublicV2> {
  // 2
  User.find(req.parameters.get("userID"), on: req.db)
    .unwrap(or: Abort(.notFound))
    .convertToPublicV2()
}

这个方法就像getHandler(_:)一样,有两个变化:

  1. 返回一个User.PublicV2
  2. 调用convertToPublicV2()以产生正确的返回项。

最后,在boot(rouse:)的末尾添加以下内容:

// API Version 2 Routes
// 1
let usersV2Route = routes.grouped("api", "v2", "users")
// 2
usersV2Route.get(":userID", use: getV2Handler)

下面是这个的作用:

  1. 添加一个新的API组,将在/api/v2/users上解析。
  2. /api/v2/users/<USER_ID>的GET请求连接到getV2Handler()

现在你有一个新的端点来获取一个用户,在API中带有v2,返回twitterURL

Note

对于一个更复杂的API修订,你应该创建新的控制器来处理新的API版本。这将简化你对代码的推理,使其更容易维护。

更新网站

你的应用程序现在已经具备了存储用户的Twitter帐号所需的一切,API也已经完成。你需要更新网站以允许新用户在注册过程中提供一个Twitter地址。

打开register.leaf,在name的表单组后添加以下内容:

<div class="form-group">
  <label for="twitterURL">Twitter handle</label>
  <input type="text" name="twitterURL" class="form-control"
   id="twitterURL"/>
</div>

这在注册表上增加了一个Twitter手柄的字段。接下来,打开user.leaf并将<h2>#(user.username)</h2>替换为以下内容:

<h2>#(user.username)
  #if(user.twitterURL):
  - @#(user.twitterURL)
  #endif
</h2>

这将在用户信息页面上显示Twitter的手柄(如果它存在的话)。最后,打开WebsiteController.swift,在RegisterData的末尾添加以下内容:

let twitterURL: String?

这允许你的表单处理程序访问从浏览器发送的Twitter信息。在registerPostHandler(_:data:)中,替换:

let user = User(
  name: data.name,
  username: data.username,
  password: password)

为:

var twitterURL: String?
if let twitter = data.twitterURL,
   !twitter.isEmpty {
    twitterURL = twitter
}
let user = User(
  name: data.name,
  username: data.username,
  password: password,
  twitterURL: twitterURL)

如果用户没有提供Twitter帐号,你要在数据库中存储nil而不是一个空字符串。

建立并运行。在你的浏览器中访问http://localhost:8080/,并注册一个新的用户,提供一个Twitter手柄。访问用户的信息页面,看看你的手工作品的结果!

img

使类别独一无二

就像你要求用户名是唯一的一样,你真的希望类别名称也是唯一的。到目前为止,你为实现分类所做的一切都使其不可能产生重复的名字,但你希望在数据库中也能强制执行。现在是时候创建一个Migration,以保证重复的类别名称不会被插入到数据库中。

首先,在Migrations目录下创建一个新文件,名为21-01-14-MakeCategoriesUnique.swift。打开这个新文件并输入以下内容:

import Fluent

// 1
struct MakeCategoriesUnique: Migration {
  // 2
  func prepare(on database: Database) -> EventLoopFuture<Void> {
    // 3
    database.schema(Category.v20210113.schemaName)
      // 4
      .unique(on: Category.v20210113.name)
      // 5
      .update()
  }

  // 6
  func revert(on database: Database) -> EventLoopFuture<Void> {
    // 7
    database.schema(Category.v20210113.schemaName)
      // 8
      .deleteUnique(on: Category.v20210113.name)
      // 9
      .update()
  }
}
  1. 定义一个新的类型,MakeCategoriesUnique,符合Migration
  2. 定义所需的prepare(on:)
  3. 选择Category模式,告诉Fluent改变类别的表。
  4. 使用unique(on:)添加一个新的唯一索引,与name的键对应。
  5. 由于Category已经存在于你的数据库中,使用update()来修改数据库。
  6. 定义所需的revert(on:)
  7. 选择Category模式,告诉Fluent改变类别的表。
  8. 使用deleteUnique(on:)来删除与name键对应的索引。
  9. 由于Category已经存在于你的数据库中,使用update()来修改数据库。

最后,打开configure.swift,将MakeCategoriesUnique注册为其中一个迁移。在app.migrations.add(CreateAdminUser())后面添加以下内容:

app.migrations.add(MakeCategoriesUnique())

建立并运行;观察控制台中的新迁移:

img

基于环境的播种

在第18章,"API认证,第一部分 "中,你在数据库中播种了一个管理员用户。正如那里提到的,你不应该使用"password"作为你的管理密码。但是,当你还在开发中,只需要一个假的账户在本地进行测试时,这就比较容易。确保你在生产中不添加这个用户的一个方法是在添加迁移之前检测你的环境。在configure.swift替换:

app.migrations.add(CreateAdminUser())

为以下内容:

switch app.environment {
case .development, .testing:
  app.migrations.add(CreateAdminUser())
default:
  break
}

现在,AdminUser只在应用程序处于开发(默认)或测试环境时被添加到迁移中。如果环境是生产环境,迁移就不会发生。当然,你仍然希望在你的生产环境中有一个拥有随机密码的管理员。在这种情况下,你可以在AdminUser里面切换环境,或者你可以创建两个版本,一个用于开发,一个用于生产。

接下来去哪?

在本章中,你学到了如何在你的应用进入生产阶段后,使用迁移来修改你的数据库。你看到了如何为User添加一个额外的属性--twitterUrl,如何恢复这个更新,以及如何执行类别名称的唯一性。最后,你看到了如何在configure.swift中开启你的环境,允许你将迁移从生产环境中排除。

你可以在Vapor的文档中了解更多关于迁移的信息,网址是https://docs.vapor.codes/4.0/fluent/migration/