跳转至

第17章:制作一个简单的网络应用程序,第二部分

在上一章中,你学会了如何查看类别以及如何创建、编辑和删除首字母缩写。在这一章中,你将学习如何以用户友好的方式让用户为首字母缩写添加类别。

创建有类别的缩略语

网络应用的最后一项实现任务是允许用户管理缩写的类别。当使用REST客户端(如iOS应用)的API时,你可以发送多个请求,每个类别一个。然而,这在网络浏览器中是不可行的。

网络应用必须接受一个请求中的所有信息,并将该请求翻译成适当的Fluent操作。此外,在用户可以选择类别之前必须先创建类别,这并不能创造良好的用户体验。

打开Category.swift,在底部添加以下扩展:

extension Category {
  static func addCategory(
    _ name: String,
    to acronym: Acronym,
    on req: Request
  ) -> EventLoopFuture<Void> {
    // 1
    return Category.query(on: req.db)
      .filter(\.$name == name)
      .first()
      .flatMap { foundCategory in
        if let existingCategory = foundCategory {
          // 2
          return acronym.$categories
            .attach(existingCategory, on: req.db)
        } else {
          // 3
          let category = Category(name: name)
          // 4
          return category.save(on: req.db).flatMap {
            // 5
            acronym.$categories
              .attach(category, on: req.db)
          }
        }
    }
  }
}

以下是这个新扩展的作用:

  1. 进行查询,用所提供的名称搜索一个类别。
  2. 如果该类别存在,则建立关系。
  3. 如果类别不存在,用提供的名称创建一个新的Category对象。
  4. 保存新的类别,并解开返回的future
  5. 使用保存的缩略语设置关系。

打开WebsiteController.swift,在文件底部添加一个新的Content类型来处理接受的类别:

struct CreateAcronymFormData: Content {
  let userID: UUID
  let short: String
  let long: String
  let categories: [String]?
}

这与AcronymsController.swift中现有的CreateAcronymData类似。CreateAcronymFormData增加了一个可选的String数组来表示类别。这允许用户提交现有的和新的类别,而不是只有现有的类别。

接下来,将createAcronymPostHandler(_:)替换为以下内容:

func createAcronymPostHandler(_ req: Request) throws
  -> EventLoopFuture<Response> {
  // 1
  let data = try req.content.decode(CreateAcronymFormData.self)
  let acronym = Acronym(
    short: data.short, 
    long: data.long, 
    userID: data.userID)
  // 2
  return acronym.save(on: req.db).flatMap {
    guard let id = acronym.id else {
      // 3
      return req.eventLoop
        .future(error: Abort(.internalServerError))
    }
    // 4
    var categorySaves: [EventLoopFuture<Void>] = []
    // 5
    for category in data.categories ?? [] {
      categorySaves.append(
        Category.addCategory(
          category, 
          to: acronym, 
          on: req))
    }
    // 6
    let redirect = req.redirect(to: "/acronyms/\(id)")
    return categorySaves.flatten(on: req.eventLoop)
      .transform(to: redirect)
  }
}

以下是你改变的内容:

  1. 改变Content类型以解码CreateAcronymFormData
  2. 使用flatMap(_:)而不是map(:_),因为你现在在闭包中返回一个EventLoopFuture
  3. 如果缩写保存失败,返回一个失败的EventLoopFuture,而不是抛出错误,因为你不能在flatMap(_:)中抛出。
  4. 定义一个期货数组来存储保存操作。
  5. 循环浏览请求中提供的所有类别,并将Category.addCategory(_:to:on:)的结果添加到期货数组中。
  6. 扁平化数组以完成所有的Fluent操作,并将结果转化为Response。将页面重定向到新的首字母缩写的页面。

接下来,你需要允许用户在创建首字母缩略时指定类别。打开createAcronym.leaf,在<button>部分上方,添加以下内容:

<!-- 1 -->
<div class="form-group">
  <!-- 2 -->
  <label for="categories">Categories</label>
  <!-- 3 -->
  <select name="categories[]" class="form-control"
   id="categories" placeholder="Categories" multiple="multiple">
  </select>
</div>

这是它的作用:

  1. 为类别定义一个新的<div>,用form-group类的样式。
  2. 为输入指定一个标签。
  3. 定义一个<select>输入,让用户指定类别。multiple属性让用户指定多个选项。名称categories[]允许表单将类别作为URL编码的数组发送。

目前,该表格显示没有任何类别。使用<select>输入法只允许用户选择预先定义的类别。为了让用户有良好的体验,你将使用Select2 JavaScript library

打开base.leaf,在<link rel=stylesheet...下为Bootstrap样式表添加以下内容:

#if(title == "Create An Acronym" || title == "Edit Acronym"):
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css" integrity="sha384-KZO2FRYNmIHerhfYMjCIUaJeGBRXP7CN24SiNSG+wdDzgwvxWbl16wMVtWiJTcMt" crossorigin="anonymous">
#endif

这将Select2的样式表添加到创建和编辑缩略语页面。注意复杂的Leaf语句。在base.leaf的底部,删除jQuery的第一个<script>标签,用以下内容代替:

<!-- 1 -->
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha384-ZvpUoO/+PpLXR1lu4jmpXWu80pZlYUAfxl5NsBMWOEPSjUn/6Z/hRTt8+pR6L4N2" crossorigin="anonymous"></script>
<!-- 2 -->
#if(title == "Create An Acronym" || title == "Edit Acronym"):
  <script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js" integrity="sha384-JnbsSLBmv2/R0fUmF2XYIcAEMPHEAO51Gitn9IjL4l89uFTIgtLF1+jqIqqd9FSk" crossorigin="anonymous"></script>
  <!-- 3 -->
  <script src="/scripts/createAcronym.js"></script>
#endif

Here’s what this does:

  1. Include the full jQuery library. Bootstrap only requires the slim version, but Select2 requires functionality not included in the slim version, so must include the full library.
  2. If the page is the create or edit acronym page, include the JavaScript for Select2.
  3. Also include the local createAcronym.js.

Create a directory in Public called scripts for your local JavaScript file. In the new directory, create createAcronym.js. Open the new file and insert the following:

下面是这个的作用:

  1. 包括完整的jQuery库。Bootstrap只需要薄的版本,但Select2需要的功能不包括在薄的版本中,所以必须包括全库。
  2. 如果该页面是创建或编辑首字母缩写的页面,则包括Select2JavaScript
  3. 同时包括本地的createAcronym.js

Public创建一个名为scripts的目录,用于存放你的本地JavaScript文件。在这个新目录中,创建createAcronym.js。打开新文件并插入以下内容:

// 1
$.ajax({
  url: "/api/categories/",
  type: "GET",
  contentType: "application/json; charset=utf-8"
}).then(function (response) {
  var dataToReturn = [];
  // 2
  for (var i=0; i < response.length; i++) {
    var tagToTransform = response[i];
    var newTag = {
                   id: tagToTransform["name"],
                   text: tagToTransform["name"]
                 };
    dataToReturn.push(newTag);
  }
  // 3
  $("#categories").select2({
    // 4
    placeholder: "Select Categories for the Acronym",
    // 5
    tags: true,
    // 6
    tokenSeparators: [','],
    // 7
    data: dataToReturn
  });
});

以下是该脚本的作用:

  1. 在页面加载时,向/api/categories发送一个GET请求。这将获得TIL应用程序中的所有类别。
  2. 循环浏览每个返回的类别,将其变成一个JSON对象,并将其添加到dataToReturn中。这个JSON对象看起来像:
{
  "id": <id of the category>,
  "text": <name of the category>
}
  1. 获取IDcategoriesHTML元素并对其调用select2()。这样就可以在表单中的<select>上启用Select2
  2. Select2输入上设置占位符文本。
  3. Select2中启用标签。这允许用户动态地创建在输入中不存在的新类别。
  4. 设置Select2的分隔符。当用户输入时,Select2会根据输入的文本创建一个新的类别。这允许用户用空格来创建类别。
  5. 将数据--用户可以选择的选项--设置为现有的类别。

保存文件,然后在Xcode中构建并运行该应用程序。导航到创建首字母缩写页面。类别列表允许您输入现有的类别或创建新的类别。该列表还允许您以一种用户友好的方式添加和删除"标签":

img

显示类别

现在,打开acronym.leaf。在"创建者"段落下添加以下内容:

<!-- 1 -->
#if(count(categories) > 0):
  <!-- 2 -->
  <h3>Categories</h3>
  <ul>
    <!-- 3 -->
    #for(category in categories):
      <li>
        <a href="/categories/#(category.id)">
          #(category.name)
        </a>
      </li>
    #endfor
  </ul>
#endif

下面是这个的作用:

  1. 检查模板上下文是否有任何类别。
  2. 如果有,创建一个标题和一个<ul>列表。
  3. 循环浏览所提供的类别,为每个类别添加一个链接。

保存文件并打开WebsiteController.swift。在AcronymContext的底部为类别添加一个新属性:

let categories: [Category]

acronymHandler(_:)中,替换:

acronym.$user.get(on: req.db).flatMap { user in
  let context = AcronymContext(
    title: acronym.short, 
    acronym: acronym, 
    user: user)
  return req.view.render("acronym", context)
}

为下面内容:

let userFuture = acronym.$user.get(on: req.db)
let categoriesFuture = 
  acronym.$categories.query(on: req.db).all()
return userFuture.and(categoriesFuture)
  .flatMap { user, categories in
    let context = AcronymContext(
      title: acronym.short,
      acronym: acronym,
      user: user,
      categories: categories)
    return req.view.render("acronym", context)
}

这可以得到首字母缩写的类别以及它的用户。建立并运行,然后在浏览器中打开创建首字母缩写的页面。在浏览器中创建一个带有类别的首字母缩写,然后进入该首字母缩写的页面。你会在页面上看到该首字母缩写的类别:

img

编辑缩略语

为了在编辑首字母缩写时允许添加和编辑类别,请打开createAcronym.leaf。在类别<div>中,在<select></select>标签之间,添加以下内容:

#if(editing):
  <!-- 1 -->
  #for(category in categories):
    <!-- 2 -->
    <option value="#(category.name)" selected="selected">
      #(category.name)
    </option>
  #endfor
#endif

下面是这个的作用:

  1. 如果editing标志被设置,循环浏览所提供的类别阵列。
  2. 将每个类别添加为<选项>,并设置selected属性。这允许在编辑表格时预先填入类别标签。

保存该文件。打开WebsiteController.swift,在EditAcronymContext的底部添加一个新属性:

let categories: [Category]

editAcronymHandler(_:)替换:

let context = EditAcronymContext(acronym: acronym, users: users)
return req.view.render("createAcronym", context)

为下面的内容:

acronym.$categories.get(on: req.db).flatMap { categories in
  let context = EditAcronymContext(
    acronym: acronym, 
    users: users, 
    categories: categories)
  return req.view.render("createAcronym", context)
}

这将获得缩写的类别,并将它们传递给你新的EditAcronymContext。最后,将editAcronymPostHandler(_:)替换为以下内容:

func editAcronymPostHandler(_ req: Request) throws 
  -> EventLoopFuture<Response> {
  // 1
  let updateData = 
    try req.content.decode(CreateAcronymFormData.self)
  return Acronym
    .find(req.parameters.get("acronymID"), on: req.db)
    .unwrap(or: Abort(.notFound)).flatMap { acronym in
      acronym.short = updateData.short
      acronym.long = updateData.long
      acronym.$user.id = updateData.userID
      guard let id = acronym.id else {
        return req.eventLoop
          .future(error: Abort(.internalServerError))
      }
      // 2
      return acronym.save(on: req.db).flatMap {
        // 3
        acronym.$categories.get(on: req.db)
      }.flatMap { existingCategories in
        // 4
        let existingStringArray = existingCategories.map {
          $0.name
        }

        // 5
        let existingSet = Set<String>(existingStringArray)
        let newSet = Set<String>(updateData.categories ?? [])

        // 6
        let categoriesToAdd = newSet.subtracting(existingSet)
        let categoriesToRemove = existingSet
          .subtracting(newSet)

        // 7
        var categoryResults: [EventLoopFuture<Void>] = []
        // 8
        for newCategory in categoriesToAdd {
          categoryResults.append(
            Category.addCategory(
              newCategory,
              to: acronym,
              on: req))
        }

        // 9
        for categoryNameToRemove in categoriesToRemove {
          // 10
          let categoryToRemove = existingCategories.first {
            $0.name == categoryNameToRemove
          }
          // 11
          if let category = categoryToRemove {
            categoryResults.append(
              acronym.$categories.detach(category, on: req.db))
          }
        }

        let redirect = req.redirect(to: "/acronyms/\(id)")
        // 12
        return categoryResults.flatten(on: req.eventLoop)
          .transform(to: redirect)
      }
  }
}

这个新版本中的重要内容是:

  1. 将请求解码的内容类型改为CreateAcronymFormData
  2. save(on:)上使用flatMap(_:),但返回所有首字母缩写的类别。注意期货的链式结构,而不是嵌套式结构。这有助于提高你代码的可读性。
  3. 从数据库中获取所有类别。
  4. 从数据库中的类别创建一个类别名称数组。
  5. 为数据库中的类别创建一个Set,为请求中提供的类别创建另一个Set
  6. 计算要添加到缩略语中的类别和要删除的类别。
  7. 创建一个类别操作结果的数组。
  8. 循环浏览所有要添加的类别,并调用Category.addCategory(_:to:on:)来建立关系。将每个结果添加到结果数组中。
  9. 循环浏览所有要从缩写中删除的类别名称。
  10. 从要删除的类别名称中获取Category对象。
  11. 如果Category对象存在,使用detach(_:on:)来删除关系并删除枢纽。
  12. 扁平化所有未来的类别结果。将结果转化为重定向到更新的缩略语页面。

建立并运行,然后在浏览器中打开一个首字母缩写页面。

点击Edit,你会看到表格中填充了现有的类别:

img

添加一个新的类别,然后点击Update。页面会重定向到首字母缩写的页面,并显示更新后的首字母缩写。现在试着从一个缩写中删除一个类别。

接下来去哪?

在本节中,你学习了如何创建一个全功能的Web应用程序,执行与iOS应用程序相同的功能。你学会了如何使用Leaf来显示不同类型的数据并与future一起工作。你还学习了如何接受来自Web表单的数据,并为处理数据提供良好的用户体验。

TIL应用包含API和网络应用。这对小型应用来说效果很好,但对于非常大的应用,你可以考虑将它们拆分成自己的应用。然后,网络应用像任何其他客户端一样与API对话,比如iOS应用。这使你可以单独扩展不同的部分。大型应用甚至可以由不同的团队开发。将它们分割开来,可以让应用程序成长和变化,而不需要依赖其他团队。

在本书的下一节,你将学习如何将认证应用于你的应用程序。目前,任何人都可以在iOS应用和Web应用中创建任何首字母缩写。这并不可取,特别是对于大型系统来说。接下来的章节会告诉你如何用认证来保护APIWeb应用。