跳转至

第26章:添加个人资料图片

在前几章中,你学会了如何用POST请求向你的Vapor应用程序发送数据。你使用JSON体和表单来传输数据,但数据总是简单的文本。在本章中,你将学习如何在请求中发送文件,并在你的Vapor应用程序中处理这些文件。你将使用这些知识来允许用户在Web应用程序中上传个人资料图片。

Note

本章教你如何向运行Vapor应用程序的服务器上传文件。对于真正的应用,你应该考虑将文件转发到存储服务,如AWS S3。许多主机供应商,如Heroku,不提供持久性存储。这意味着在重新部署应用程序时,你会丢失你上传的文件。如果托管提供商重新启动你的应用程序,你也会丢失文件。此外,将文件上传到同一台服务器意味着你不能将你的应用程序扩展到一个以上的实例,因为这些文件不会存在于所有的应用程序实例。

将图片添加到模型中

和前几章一样,你需要改变模型,这样你就可以把图片和User联系起来。在Xcode中打开Vapor TIL应用程序,打开User.swift。在下面添加以下内容 var email: 字符串

@OptionalField(key: "profilePicture")
var profilePicture: String?

这为图片存储了一个可选的String。它将包含用户在磁盘上的个人资料图片的文件名。文件名是可选的,因为你并不强制要求用户有一张个人资料图片--当他们注册时,他们也不会有一张。将初始化器替换为以下内容,以说明新的属性:

init(
  name: String,
  username: String,
  password: String,
  siwaIdentifier: String? = nil,
  email: String,
  profilePicture: String? = nil
) {
  self.name = name
  self.username = username
  self.password = password
  self.siwaIdentifier = siwaIdentifier
  self.email = email
  self.profilePicture = profilePicture
}

profilePicture提供一个默认值nil,可以让你的应用程序继续编译和运行,而不需要进一步修改源代码。

Note

你可以使用GoogleGitHub的用户API来获取用户的个人资料图片的URL。这将允许你下载图片并将其与普通用户的图片一起存储,或保存链接。然而,这只是留给读者的一个练习。

你可以把上传个人资料图片作为注册经验的一部分,但本章将其作为一个单独的步骤。注意到UsersController中的createHandler(_:)不需要为新属性做任何改变。这是因为路由处理程序使用了Codable,如果POST请求中不存在数据,则将该属性设置为`nil'。

接下来,打开CreateUser.swift和找到下文:

.field("email", .string, .required)`:

然后添加下面的内容:

.field("profilePicture", .string)

这将在数据库中为个人资料图片添加一个新的列。注意,你没有添加.required约束,因为这个属性是可选的。

重置数据库

和过去一样,由于你已经为User添加了一个属性,你必须重置数据库。在终端,运行:

docker rm -f postgres
docker rm -f postgres-test
docker run --name postgres -e POSTGRES_DB=vapor_database \
  -e POSTGRES_USER=vapor_username \
  -e POSTGRES_PASSWORD=vapor_password \
  -p 5432:5432 -d postgres
docker run --name postgres-test -e POSTGRES_DB=vapor-test \
  -e POSTGRES_USER=vapor_username \
  -e POSTGRES_PASSWORD=vapor_password \
  -p 5433:5432 -d postgres

像以前一样,这将删除现有的名为postgres的容器并重新创建它。它也重置了用于测试的数据库。确保两个容器都在运行。在终端,键入:

docker ps -a

你应该看到你的主数据库容器postgres,和测试数据库容器postgres-test。两者都应该有一个类似于Up about a minute的状态:

img

验证测试

Xcode中,输入Command+U来运行所有的测试。他们应该全部通过。

Note

Xcode使用来自.env的现有环境变量。如果你使用本章的启动项目而不是现有的项目,你应该确保你正确地设置这些变量。你还需要设置自定义工作目录,以便Vapor知道在哪里找到文件。关于这些设置的细节,请参见第22-25章。这四章中的每一章都贡献了必要的环境变量。

创建表单

随着模型的改变,你现在可以创建一个页面,让用户提交图片。在Xcode中,打开WebsiteController.swift。接下来,在下面添加resetPasswordPostHandler(_:data:)

func addProfilePictureHandler(_ req: Request) 
  -> EventLoopFuture<View> {
    User.find(req.parameters.get("userID"), on: req.db)
      .unwrap(or: Abort(.notFound)).flatMap { user in
        req.view.render(
          "addProfilePicture", 
          [
            "title": "Add Profile Picture", 
            "username": user.name
          ]
        )
    }
}

这定义了一个新的路由处理程序,渲染addProfilePicture.leaf。这个路由处理程序还将标题和用户的名字作为一个字典传递给模板。接下来,在boot(routes:)的末尾添加以下内容,以注册新的路由处理程序:

protectedRoutes.get(
  "users", 
  ":userID", 
  "addProfilePicture", 
  use: addProfilePictureHandler)

这将一个GET请求连接到/users/<user_ID>/addProfilePictureaddProfilePictureHandler(_:)。请注意,该路由也是一个受保护的路由--用户必须登录才能给用户添加配置文件图片。

TIL应用程序还允许用户为任何用户上传个人资料图片,而不仅仅是他们自己的。

Resources/Views中,创建新的模板,addProfilePicture.leaf。在任何文本编辑器中打开这个新文件,并插入以下内容:

<!-- 1 -->
#extend("base"):
  <!-- 2 -->
  #export("content"):
    <!-- 3 -->
    <h1>#(title)</h1>

    <!-- 4 -->
    <form method="post" enctype="multipart/form-data">
      <!-- 5 -->
      <div class="form-group">
        <label for="picture">
            Select Picture for #(username)
        </label>
        <input type="file" name="picture"
        class="form-control-file" id="picture"/>
      </div>

      <!-- 6 -->
      <button type="submit" class="btn btn-primary">
        Upload
      </button>
    </form>
  #endexport
#endextend

以下是新模板的作用:

  1. 扩展base.leaf以包括主模板。
  2. 按照base.leaf的要求导出content
  3. 使用传递给模板的标题作为页面的标题。
  4. 创建一个表单,并将方法设置为POST。当你提交表单时,浏览器会将表单作为一个POST请求发送到同一个URL。注意multipart/form-data的编码类型。这允许你从浏览器中向服务器发送文件。
  5. 创建一个输入类型为file的表单组。这在你的网络浏览器中呈现一个文件浏览器。Bootstrap使用form-control-file来帮助输入的样式。
  6. 添加一个提交按钮,允许用户提交表单。

接下来,你需要一个链接,让用户能够访问新的表单。打开WebsiteController.swift,在UserContext的底部添加一个新属性:

let authenticatedUser: User?

这将存储该请求的认证用户,如果存在的话。在userHandler(_:)中,将let context = ...替换为以下内容:

// 1
let loggedInUser = req.auth.get(User.self)
// 2
let context = UserContext(
  title: user.name,
  user: user,
  acronyms: acronyms,
  authenticatedUser: loggedInUser)

以下是你改变的内容:

  1. Request的认证缓存中获取认证的用户。这将返回User?,因为可能没有认证的用户。
  2. 将可选的、已认证的用户传递给上下文。

最后,打开user.leaf。在#extend("acronymsTable")前添加以下内容:

#if(authenticatedUser):
  <a href="/users/#(user.id)/addProfilePicture">
    #if(user.profilePicture): 
      Update 
    #else: 
      Add 
    #endif 
    Profile Picture
  </a>
#endif

如果用户已经登录,这将添加一个链接到新的添加个人资料图片页面。如果用户已经有了个人资料图片,该链接将显示Update Profile Picture,否则链接将显示Add Profile Picture

在Xcode中,构建并运行该应用程序。在浏览器中,访问http://localhost:8080/login,并作为管理员用户登录。一旦登录,点击All Users并选择admin用户。

有一个新的链接到添加个人资料图片页面。点击Add Profile Picture,你会看到新的表格来添加个人资料图片:

img

接受文件上传

接下来,实现必要的代码来处理表单中的POST请求。在终端,在TILApp目录下输入以下内容:

# 1
mkdir ProfilePictures
# 2
touch ProfilePictures/.keep

以下是这些命令的作用:

  1. 创建目录来存储用户的个人资料图片。
  2. 添加一个空文件,以便将该目录添加到源控制中。这有助于部署应用程序,确保目录的存在。

接下来,在Xcode中,打开WebsiteController.swift。在文件的底部,添加以下内容:

struct ImageUploadData: Content {
  var picture: Data
}

这个新类型代表表单发送的数据。pictureHTML表单中指定的输入名称相匹配。

由于表单上传了一个文件,你将把图片解码成Data

接下来,在WebsiteController的顶部添加一个新的属性,在boot(rouse:)的上方:

let imageFolder = "ProfilePictures/"

这定义了你将存储图片的文件夹。接下来,在addProfilePictureHandler(_:)下面,为POST请求添加一个请求处理器:

func addProfilePicturePostHandler(_ req: Request) 
  throws -> EventLoopFuture<Response> {
    // 1
    let data = try req.content.decode(ImageUploadData.self)
    // 2
    return User.find(req.parameters.get("userID"), on: req.db)
      .unwrap(or: Abort(.notFound))
      .flatMap { user in
        // 3
        let userID: UUID
        do {
          userID = try user.requireID()
        } catch {
          return req.eventLoop.future(error: error)
        }
        // 4
        let name = "\(userID)-\(UUID()).jpg"
        // 5
        let path = 
          req.application.directory.workingDirectory + 
            imageFolder + name
        // 6
        return req.fileio
          .writeFile(.init(data: data.picture), at: path)
          .flatMap {
            // 7
            user.profilePicture = name
            // 8
            let redirect = req.redirect(to: "/users/\(userID)")
            return user.save(on: req.db).transform(to: redirect)
        }
    }
}

以下是新的请求处理程序的作用:

  1. 将请求主体解码为ImageUploadData
  2. 从参数中获取用户。
  3. 获取用户ID并捕捉任何抛出的错误。
  4. 为个人资料图片创建一个独特的名称。
  5. 使用应用程序的工作目录、图片文件夹和名称来设置文件的保存路径。
  6. 使用路径和图片数据在磁盘上保存文件。这使用了NIO的文件功能,以避免在等待写入完成时阻塞任何线程。
  7. 用个人资料图片的文件名更新用户。
  8. 保存更新后的用户,并返回一个重定向到用户的页面。

最后,在boot(route:)的底部注册路由:

protectedRoutes.on(
  .POST, 
  "users", 
  ":userID", 
  "addProfilePicture", 
  body: .collect(maxSize: "10mb"), 
  use: addProfilePicturePostHandler)

这与所有其他的路由注册有一点不同。这仍然是将/users/<USER_ID>/addProfilePicturePOST请求连接到addProfilePicturePostHandler(_:)。然而,在默认情况下,Vapor将流媒体主体收集限制在16KB,以节省内存消耗。你可以在全局或每条路由的基础上改变这一点。这个路由注册只对这个路由改变允许的最大尺寸为10MB

显示图片

现在,用户可以上传个人资料图片,你需要能够将图片送回给浏览器。通常情况下,你会使用FileMiddleware。然而,由于你将图片存储在不同的目录中,本章将教你如何手动提供图片。

WebsiteController.swift中,在addProfilePicturePostHandler(_:)下面添加一个新的路由处理器:

func getUsersProfilePictureHandler(_ req: Request)
  -> EventLoopFuture<Response> {
    // 1
    User.find(req.parameters.get("userID"), on: req.db)
      .unwrap(or: Abort(.notFound))
      .flatMapThrowing { user in
      // 2
      guard let filename = user.profilePicture else {
        throw Abort(.notFound)
      }
      // 3
      let path = req.application.directory
        .workingDirectory + imageFolder + filename
      // 4
      return req.fileio.streamFile(at: path)
    }
}

以下是新的路径处理程序的作用:

  1. 从请求的参数中获取用户。
  2. 确保用户有一张保存的个人资料图片,否则会出现404 Not Found错误。
  3. 构建用户的个人资料图片的路径。
  4. 使用VaporFileIO方法,将文件作为Response返回。这将处理读取文件并将正确的信息返回给浏览器。

接下来,在boot(routes:)下面的authSessionsRoutes.get("categories", ":categoryID", use: categoryHandler)注册新路由:

authSessionsRoutes.get(
  "users", 
  ":userID", 
  "profilePicture", 
  use: getUsersProfilePictureHandler)

这就把对/users/<user_ID>/profilePictureGET请求连接到getUsersProfilePictureHandler(_:)。最后,打开user.leaf。在<h1>#(user.name)</h1>前加入以下内容:

#if(user.profilePicture):
  <img src="/users/#(user.id)/profilePicture"
   alt="#(user.name)">
#endif

这将检查传递到模板上下文的用户是否有一张个人资料图片。如果有,Leaf会将图片添加到页面中。

建立并运行应用程序,在浏览器中进入http://localhost:8080/login。以默认的管理员用户身份登录,然后导航到管理员用户的个人资料页面。点击Add Profile Picture,在表格中点击Choose File。选择一个要上传的图片,然后点击Upload

该网站将把你重定向到用户的个人资料页面,在那里你会看到上传的图片:

img

接下来去哪?

在本章中,你学到了如何在Vapor中处理文件。你看到了如何处理文件的上传和保存到磁盘。你还学习了如何在路由处理程序中从磁盘上提供文件。

现在你已经建立了一个功能齐全的API,展示了Vapor的许多功能。你已经建立了一个iOS应用程序来消费API,以及一个使用Leaf的前端网站。你还学会了如何测试你的应用程序。

这些部分已经给了你所有的知识,你需要为你自己的应用程序建立后端和网站! 接下来的章节涵盖了你可能需要的更多高级主题,如数据库迁移和缓存。你还将学习如何将你的应用程序部署到互联网上。