Vue3-示例-三-

56 阅读40分钟

Vue3 示例(三)

原文:zh.annas-archive.org/md5/84EBE0BE98F4DE483EBA9EF82A25ED12

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:使用 GraphQL 创建购物车系统

在上一章中,我们使用 Vue 3 和 Express 构建了一个旅行预订系统。这是我们从头开始构建自己的后端的第一个项目,该后端被前端使用。拥有自己的后端让我们能够做更多的事情,否则我们无法做到,例如,我们可以将喜欢的数据保存在我们自己创建的数据库中。此外,我们为管理员用户添加了自己的身份验证系统。在管理员前端,我们使用beforeEnter路由守卫保护我们的路由,在管理员用户登录之前检查身份验证令牌。

在本章中,我们将研究以下主题:

  • 介绍 GraphQL 应用程序编程接口(API)

  • 使用 Express 创建 GraphQL API

  • 创建管理员前端

  • 创建客户前端

技术要求

本章项目的代码可以在github.com/PacktPublishing/-Vue.js-3-By-Example/tree/master/Chapter07找到。

介绍 GraphQL API

在上一章中,我们使用 Express 创建了一个后端。该端点接受 JSON 数据作为输入,并返回 JSON 数据作为响应。然而,它可以接受任何 JSON 数据,后端可能不会预期。此外,没有一种简单的方法可以在没有前端的情况下测试我们的 API 端点。这是我们可以用 GraphQL API 解决的问题。GraphQL 是一种特殊的查询语言,使客户端和服务器之间的通信更加容易。GraphQL API 具有内置的数据结构验证。每个属性都有一个数据类型,可以是简单或复杂类型,由许多具有简单数据类型的属性组成。

我们还可以使用 GraphiQL 测试 GraphQL API,这是一个网页,让我们轻松地进行自己的 GraphQL API 请求。由于每个请求都有数据类型验证,它可以根据 GraphQL API 模式的定义提供自动完成功能。该模式为我们提供了与查询和变异一起使用的所有数据类型定义。查询是让我们使用 GraphQL API 查询数据的请求,而变异是让我们以某种方式更改数据的 GraphQL 请求。

我们使用模式字符串明确定义查询和变异。查询和变异将输入类型作为输入数据的数据类型,并使用指定的输出数据类型返回数据。因此,我们永远不会对我们必须发送的数据的结构产生任何疑问,也永远不必猜测请求将返回什么类型的数据。

GraphQL API 请求基本上只是常规的超文本传输协议HTTP)请求,只是它们具有特殊的结构。所有请求默认都发送到/graphql端点,并且我们将查询或变异作为 JSON 请求中query属性的字符串值发送。变量值与variable参数一起发送。

查询和变异是有名称的,并且所有查询和变异都以相同的名称发送到解析器函数中的代码,而不是路由处理程序。然后函数根据模式指定的参数获取请求数据,并在解析器函数代码中对其进行处理。

对于 Vue 3 应用程序,我们可以使用专门的 GraphQL API 客户端来更轻松地创建 GraphQL API 请求。我们只需传入一个字符串来进行查询和变异,以及与查询和变异相关的变量。

在本章中,我们将使用 Vue 3 创建一个带有管理前端和客户前端的购物车系统。然后,我们将使用 Express 和express-graphql库创建一个后端,该后端接收 GraphQL API 请求并将数据存储在 SQLite 数据库中。

设置购物车系统项目

为了创建度假预订项目,我们必须为前端、管理前端和后端创建子项目。为了创建前端和管理前端项目,我们将使用 Vue CLI。为了创建后端项目,我们将使用express-generator全局包。

为了设置本章的项目,我们执行以下步骤:

  1. 首先,我们创建一个文件夹来存放所有项目,并将其命名为shopping-cart

  2. 然后我们在主文件夹内创建admin-frontendfrontendbackend文件夹。

  3. 接下来,我们进入admin-frontend文件夹,并运行npx vue create来为admin-frontend文件夹添加 Vue 项目的脚手架代码。

  4. 如果我们被要求在当前文件夹中创建项目,我们选择Y,然后当被要求选择项目的 Vue 版本时,我们选择Vue 3。同样,我们以frontend文件夹的方式运行 Vue CLI。

  5. 要创建 Express 项目,我们运行 Express 应用程序生成器应用程序。要运行它,我们进入backend文件夹,然后运行npx express-generator

这个命令将把所有需要的文件添加到backend文件夹中。如果出现错误,请尝试以管理员身份运行express-generator包。

现在我们已经完成了项目的设置,我们可以开始编写代码了。接下来,我们将开始创建 GraphQL 后端。

使用 Express 创建 GraphQL API

要开始购物车系统项目,我们首先要使用 Express 创建一个 GraphQL API。我们从后端开始,因为我们需要它用于两个前端。要开始,我们必须添加一些需要用于操作 SQLite 数据库并向我们的应用程序添加身份验证的库。此外,我们需要启用应用程序中的跨域资源共享CORS)的库。

CORS 是一种让我们能够从浏览器向不同域中托管的端点发出请求的方法,与前端托管的域不同。

为了使我们的 Express 应用程序接受 GraphQL 请求,我们使用graphqlexpress-graphql库。要安装两者,我们运行以下命令:

npm i cors jsonwebtoken sqlite3 express-graphql graphql

安装完包后,我们就可以开始编写代码了。

使用解析器函数

首先,我们要处理解析器函数。为了添加它们,我们首先在backend文件夹中添加一个resolvers文件夹。然后,我们可以为身份验证编写解析器。在resolvers文件夹中,我们创建一个auth.js文件,并编写以下代码:

const jwt = require('jsonwebtoken');
module.exports = {
  login: ({ user: { username, password } }) => {
    if (username === 'admin' && password === 'password') {
      return { token: jwt.sign({ username }, 'secret') }
    }
    throw new Error('authentication failed');
  }
}

login方法是一个解析器函数。它接受具有usernamepassword属性的user object属性,我们使用这些属性来检查凭据。我们检查用户名是否为'admin',密码是否为'password'。如果凭据正确,我们就发出令牌。否则,我们抛出一个错误,这将作为/graphql端点的错误响应返回。

为订单逻辑添加解析器

接下来,我们为订单逻辑添加解析器。在resolvers文件夹中,我们添加orders.js文件。然后,我们开始编写解析器函数以获取订单数据。订单数据包含有关订单本身以及客户购买的商品的信息。为了添加解析器,我们编写以下代码:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  getOrders: () => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve, reject) => {
      db.serialize(() => {
        db.all(`
          SELECT *,
            orders.name AS purchaser_name,
            shop_items.name AS shop_item_name
          FROM orders
          INNER JOIN order_shop_items ON orders.order_id = 
            order_shop_items.order_id
          INNER JOIN shop_items ON 
           order_shop_items.shop_item_id = shop_items.
             shop_item_id
        `, [], (err, rows = []) => {
          ...
        });
      })
      db.close();
    })
  },
  ...
}

我们使用sqlite3.Database构造函数打开数据库,指定数据库路径。然后,我们返回一个查询所有订单及客户购买商品的承诺。订单存储在orders表中。商店库存商品存储在shop_items表中,我们有order_shop_items表来链接订单和购买的商品。

我们使用db.all方法进行select查询以获取所有数据,并使用inner join连接所有相关表以获取其他表中的相关数据。在回调中,我们编写以下代码来循环遍历行以创建order对象:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  getOrders: () => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve, reject) => {
      db.serialize(() => {
        db.all(`
          ...
        `, [], (err, rows = []) => {
          if (err) {
            reject(err)
...
          const orderArr = Object.values(orders)
          for (const order of orderArr) {
            order.ordered_items = rows
              .filter(({ order_id }) => order_id === 
                order.order_id)
              .map(({ shop_item_id, shop_item_name: name, 
                price, description }) => ({
                shop_item_id, name, price, description
              }))
          }
          resolve(orderArr)
        });
      })
      db.close();
    })
  },
  ...
}

这样我们就可以删除行中的重复订单条目。键是order_id值,值是订单数据本身。然后,我们使用Object.values方法获取所有订单值。我们将返回的数组分配给orderArr变量。然后,我们循环遍历orderArr数组,使用filter方法从原始行的数组中获取所有已订购的商店商品,以通过order_id查找商品。我们调用map从行中提取订单的商店商品数据。

我们在数据上调用resolve,以便从/graphql端点返回它作为响应。在回调的前几行中,当err为真时,我们调用reject,以便如果有错误,我们可以将错误返回给用户。

最后,我们调用db.close()来关闭数据库。我们可以在最后这样做,因为我们使用db.serialize来运行serialize回调中的所有语句,以便结构化查询语言SQL)代码可以按顺序运行。

添加订单

我们添加一个解析器函数来添加订单。为此,我们编写以下代码:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  ...
  addOrder: ({ order: { name, address, phone, ordered_items:
    orderedItems } }) => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve) => {
      db.serialize(() => {
        const orderStmt = db.prepare(`
          INSERT INTO orders (
            name,
            address,
            phone
...
                  shop_item_id: shopItemId
                } = orderItem
                orderShopItemStmt.run(orderId, shopItemId)
              }
              orderShopItemStmt.finalize()
            })
            resolve({ status: 'success' })
            db.close();
          });
      })
    })
  },
  ...
}

我们获取订单的请求有效负载,其中包括我们在参数中解构的变量。我们以相同的方式打开数据库,并且我们从相同的承诺代码和db.serialize调用开始,但在其中我们使用db.prepare方法创建一个准备好的语句。我们发出INSERT语句以将数据添加到订单条目中。

然后,我们使用要插入的变量值调用run来运行 SQL 语句。准备好的语句很好,因为我们传递给db.run的所有变量值都经过了清理,以防止 SQL 注入攻击。然后,我们调用finalize来提交事务。

接下来,我们使用db.all调用和SELECT语句获取刚刚插入到orders表中的行的 ID 值。在db.all方法的回调中,我们获取返回的数据并从返回的数据中解构orderId

然后,我们创建另一个准备好的语句,将购买的商店物品的数据插入到order_shop_items表中。我们只是插入order_idshop_item_id来将订单与购买的商店物品关联起来。

我们循环遍历orderedItems数组并调用run来添加条目,然后我们调用finalize来完成所有数据库事务。

最后,我们调用resolve向客户端返回成功响应。

为了完成这个文件,我们添加removeOrder解析器,让我们能够从数据库中删除订单。为此,我们编写以下代码:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  ...
  removeOrder: ({ orderId }) => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve) => {
      db.serialize(() => {
        const delOrderShopItemsStmt = db.prepare("DELETE FROM 
          order_shop_items WHERE order_id = (?)");
        delOrderShopItemsStmt.run(orderId)
        delOrderShopItemsStmt.finalize();
        const delOrderStmt = db.prepare("DELETE FROM orders 
          WHERE order_id = (?)");
        delOrderStmt.run(orderId)
        delOrderStmt.finalize();
        resolve({ status: 'success' })
      })
      db.close();
    })
  },
}

我们以与之前相同的方式调用db.serializedb.prepare。唯一的区别是,我们正在发出DELETE语句来删除order_shop_itemsorders表中具有给定order_id的所有内容。我们需要先从order_shop_items表中删除项目,因为订单仍然在那里被引用。

一旦我们清除了orders表之外对订单的所有引用,我们就可以在orders表中删除订单本身。

获取商店物品

我们在resolvers文件夹中创建一个shopItems.js文件,用于保存获取和设置商店物品的解析器函数。首先,我们从一个解析器函数开始获取所有商店物品。为此,我们编写以下代码:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  getShopItems: () => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve, reject) => {
      db.serialize(() => {
        db.all("SELECT * FROM shop_items", [], (err, rows = 
          []) => {
          if (err) {
            reject(err)
          }
          resolve(rows)
        });
      })
      db.close();
    })
  },
  ...
}

我们像之前一样调用db.serializedb.all。我们只是用查询获取所有的shop_items条目,然后调用resolve将选定的数据作为响应返回给客户端。

添加一个解析器函数来添加商店物品

现在,我们将添加一个解析器函数来添加商店物品。为此,我们编写以下代码:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  ...
  addShopItem: ({ shopItem: { name, description, image_url: 
    imageUrl, price } }) => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve) => {
      db.serialize(() => {
        const stmt = db.prepare(`
          INSERT INTO shop_items (
            name,
            description,
            image_url,
            price
          ) VALUES (?, ?, ?, ?)
        `
        );
        stmt.run(name, description, imageUrl, price)
        stmt.finalize();
        resolve({ status: 'success' })
      })
      db.close();
    })
  },
  ...
}

我们发出INSERT语句来插入一个条目,其中的值是从参数中解构出来的。

最后,我们通过编写以下代码添加removeShopItem解析器,让我们能够根据其 ID 从shop_items表中删除条目:

const sqlite3 = require('sqlite3').verbose();
module.exports = {
  ...
  removeShopItem: ({ shopItemId }) => {
    const db = new sqlite3.Database('./db.sqlite');
    return new Promise((resolve) => {
      db.serialize(() => {
        const stmt = db.prepare("DELETE FROM shop_items WHERE 
          shop_item_id = (?)");
        stmt.run(shopItemId)
        stmt.finalize();
        resolve({ status: 'success' })
      })
      db.close();
    })
  },
}

将解析器映射到查询和变异

我们需要将解析器映射到查询和变异,以便在进行 GraphQL API 请求时调用它们。为此,我们转到app.js文件并添加一些内容。我们还将添加一些中间件,以便我们可以启用跨域通信和对某些请求进行令牌检查。为此,我们首先编写以下代码:

const createError = require('http-errors');
const express = require('express');
const path = require('path');
const cookieParser = require('cookie-parser');
const logger = require('morgan');
const { graphqlHTTP } = require('express-graphql');
const { buildSchema } = require('graphql');
const cors = require('cors')
const shopItemResolvers = require('./resolvers/shopItems')
const orderResolvers = require('./resolvers/orders')
const authResolvers = require('./resolvers/auth')
const jwt = require('jsonwebtoken');

我们使用require函数导入所需的所有内容。我们可以用前面的代码块替换文件顶部的所有内容。我们导入解析器、CORS 中间件、GraphQL 库项和jsonwebtoken模块。

接下来,我们通过调用buildSchema函数为我们的 GraphQL API 创建模式。为此,我们编写以下代码:

...
const schema = buildSchema(`
  type Response {
    status: String
  }
  ...
  input Order {
    order_id: Int
    name: String
    address: String
    phone: String
    ordered_items: [ShopItem]
  }
  ...
  type Query {
    getShopItems: [ShopItemOutput],
    getOrders: [OrderOutput]
  }
  type Mutation {
    addShopItem(shopItem: ShopItem): Response
    removeShopItem(shopItemId: Int): Response
    addOrder(order: Order): Response
    removeOrder(orderId: Int): Response
    login(user: User): Token
  }
`);
...

完整的模式定义可以在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter07/backend/app.js找到。

我们有type关键字来定义响应的数据类型,我们有ResponseToken类型用作响应。express-graphql库将检查响应的结构是否符合数据类型中指定的内容,因此任何返回具有Response类型的查询或变异都应该有一个status string属性。这是可选的,因为字符串后面没有感叹号。

input关键字让我们定义了一个input类型。一个input类型用于指定请求有效载荷的数据结构。它们与output类型的定义方式相同,具有一系列属性,冒号后面是它们的数据类型。

我们可以将一个数据类型嵌套在另一个数据类型中,就像我们在OrderOutput类型的ordered_items属性中所做的那样。我们指定它包含一个具有ShopItemOutput数据类型的对象数组。同样,我们为Order数据类型中的ordered_items属性指定了类似的数据类型。方括号表示数据类型是一个数组。

QueryMutation是特殊的数据类型,它们让我们在冒号前添加解析器名称,在冒号后添加输出的数据类型。Query类型指定查询,Mutation类型指定变异。

接下来,我们通过编写以下代码指定了带有所有解析器的root对象:

const root = {
  ...shopItemResolvers,
  ...orderResolvers,
  ...authResolvers
}

我们只需将导入的所有解析器放入root对象中,并将所有条目展开到root对象中,以将它们合并为一个对象。

然后,我们添加authMiddleware以对一些 GraphQL 请求进行身份验证检查。为此,我们编写以下代码:

const authMiddleware = (req, res, next) => {
  const { query } = req.body
  const token = req.get('authorization')
  const requiresAuth = query.includes('removeOrder') ||
    query.includes('removeShopItem') ||
    query.includes('addShopItem')
  if (requiresAuth) {
    try {
      jwt.verify(token, 'secret');
      next()
      return
    } catch (error) {
      res.status(401).json({})
      return
    }
  }
  next();
}

我们从 JSON 请求有效负载中获取query属性,以检查 GraphQL 请求调用的查询或变异。然后,我们使用req.get方法获取authorization标头。接下来,我们定义一个requiresAuth布尔变量,以检查客户端是否正在发出调用受限制的查询或变异的请求。

如果为true,我们调用jwt.verify以使用密钥验证令牌。如果有效,则调用next继续到/graphql端点。否则,我们返回401响应。如果querymutation属性不需要身份验证,则只需调用next继续到/graphql端点。

添加中间件

接下来,我们添加了所有需要的中间件以启用跨域通信,并添加了/graphql端点以接受 GraphQL 请求。为此,我们编写以下代码:

...
const app = express();
app.use(cors())
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(authMiddleware)
app.use('/graphql', graphqlHTTP({
  schema,
  rootValue: root,
  graphiql: true,
}));
...

我们编写以下代码行以启用跨域通信:

app.use(cors())

以下代码行使我们能够接受 JSON 请求,这也是我们接受 GraphQL 请求所需的:

app.use(express.json());

以下代码行将身份验证检查添加到受限制的 GraphQL 查询中:

app.use(authMiddleware)

在以下代码块之前必须添加上述代码行:

app.use('/graphql', graphqlHTTP({
  schema,
  rootValue: root,
  graphiql: true,
}));

这样,身份验证检查将在进行 GraphQL 请求之前完成。最后,以下代码块添加了一个/graphql端点,以便我们接受 GraphQL 请求:

app.use('/graphql', graphqlHTTP({
  schema,
  rootValue: root,
  graphiql: true,
}));

grapgqlHTTP函数在传入一堆选项后返回一个中间件。我们为 GraphQL API 设置模式。rootValue属性具有包含所有解析器的对象。解析器名称应与QueryMutation类型中指定的名称匹配。将graphiql属性设置为true,以便在浏览器中转到/graphql页面时可以使用 GraphiQL Web 应用程序。

要测试经过身份验证的端点,我们可以使用 Chrome 和 Firefox 提供的ModHeader扩展程序,将身份验证标头与令牌添加到请求标头中。然后,我们可以轻松测试经过身份验证的 GraphQL 请求。

注意:

该扩展可以从chrome.google.com/webstore/detail/modheader/idgpnmonknjnojddfkpgkljpfnnfcklj?hl=en下载到 Chromium 浏览器,addons.mozilla.org/en-CA/firefox/addon/modheader-firefox/?utm_source=addons.mozilla.org&utm_medium=referral&utm_content=search下载到 Firefox 浏览器。

以下屏幕截图显示了 GraphiQL 界面的外观。我们还有ModHeader扩展,让我们可以在屏幕右上角添加所需的标头以进行身份验证请求:

图 7.1 – 带有 ModHeader 扩展的 GraphiQL

图 7.1 – 带有 ModHeader 扩展的 GraphiQL

接下来,我们创建一个db.sql脚本,让我们可以通过编写以下代码创建我们需要使用的数据库:

DROP TABLE IF EXISTS order_shop_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS shop_items;
CREATE TABLE shop_items (
  shop_item_id INTEGER NOT NULL PRIMARY KEY,
  name TEXT NOT NULL,
  description TEXT NOT NULL,
  price NUMBER NOT NULL,
  image_url TEXT NOT NULL
);
CREATE TABLE orders (
  order_id INTEGER NOT NULL PRIMARY KEY,
  name TEXT NOT NULL,
  address TEXT NOT NULL,
  phone TEXT NOT NULL
);
CREATE TABLE order_shop_items (
  order_id INTEGER NOT NULL,
  shop_item_id INTEGER NOT NULL,
  FOREIGN KEY (order_id) REFERENCES orders(order_id)
  FOREIGN KEY (shop_item_id) REFERENCES 
   shop_items(shop_item_id)
);

我们创建了在解析器脚本中使用的表。TEXT让我们在列中存储文本;INTEGER让我们存储整数;FOREIGN KEY指定引用表和列后的外键;NOT NULL使列成为必需的;DROP TABLE IF EXISTS删除表(如果存在);CREATE TABLE创建表;PRIMARY KEY指定主键列。

创建 SQLite 数据库

使用DB Browser for SQLiteDB4S)程序来创建和操作 SQLite 数据库,我们可以从sqlitebrowser.org/下载。该程序适用于 Windows、Mac 和 Linux。然后,我们可以点击New Database,将db.sqlite数据库保存在backend文件夹中,以便后端可以访问数据库。然后,在Execute SQL选项卡中,我们粘贴脚本以向数据库添加表。要将数据库更改写入磁盘,必须保存它们。要做到这一点,点击File菜单,然后点击Write Changes。我们也可以按下Ctrl + S键组合来保存更改。

最后,在package.json中,我们通过编写以下代码来更改start脚本:

{
  ...
  "scripts": {
    "start": "nodemon ./bin/www"
  },
  ...
}

我们切换nodemon,这样当我们更改代码并保存时,应用程序将重新启动。我们运行npm I –g nodemon来全局安装nodemon

现在我们已经完成了后端,可以继续进行前端,以便拥有完整的购物车系统。

创建管理员前端

现在我们已经完成了后端应用程序,我们可以继续处理前端。由于我们之前已经在admin-frontend文件夹中为管理员前端创建了 Vue 3 项目,我们只需安装我们需要的包,然后开始编写代码。我们需要graphql-request GraphQL 包和 GraphQL 客户端库,以及 VeeValidate、Vue Router、Axios 和 Yup 包。

要安装它们,我们在admin-frontend文件夹中运行以下命令:

npm i vee-validate@next vue-router@4 yup graphql graphql-request

安装完包后,我们可以开始编写代码。

处理组件

首先,我们开始处理组件。在components文件夹中,我们通过编写以下代码将TopBar组件添加到components/TopBar.vue文件中,以容纳路由链接和退出按钮:

<template>
  <p>
    <router-link to="/orders">Orders</router-link>
    <router-link to="/shop-items">Shop Items</router-link>
    <button @click="logOut">Log Out</button>
  </p>
</template>
<script>
export default {
  name: "TopBar",
  methods: {
    logOut() {
      localStorage.clear();
      this.$router.push("/");
    },
  },
};
</script>
<style scoped>
a {
  margin-right: 5px;
}
</style>

我们添加了 Vue Router router-link组件,让管理员用户点击它们以转到不同的页面。

退出按钮在点击时运行logOut方法,以清除本地存储并使用this.$router.push重定向回登录页面。/路径将映射到登录页面,我们稍后会看到。

接下来,在src/plugins文件夹中,我们添加router.js文件。为此,我们编写以下代码:

import { createRouter, createWebHashHistory } from 'vue-router'
import Login from '@/views/Login'
import Orders from '@/views/Orders'
import ShopItems from '@/views/ShopItems'
const beforeEnter = (to, from, next) => {
  try {
    const token = localStorage.getItem('token')
    if (to.fullPath !== '/' && !token) {
      return next({ fullPath: '/' })
    }
    return next()
  } catch (error) {
    return next({ fullPath: '/' })
  }
}
const routes = [
  { path: '/', component: Login },
  { path: '/orders', component: Orders, beforeEnter },
  { path: '/shop-items', component: ShopItems, beforeEnter },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})
export default router

我们添加了beforeEnter路由守卫来检查身份验证令牌是否存储在本地存储中。如果已经存储,并且我们要前往经过身份验证的路由,那么我们通过调用next而不带参数来继续到页面。否则,我们通过调用带有fullPath属性设置为'/'的对象的next来重定向回登录页面。如果有任何错误,我们也会返回到登录页面。

接下来,我们有路由映射的routes数组。我们将路径映射到组件,这样当我们在浏览器中输入统一资源定位符URL)或单击页面上的路由链接时,我们就会转到我们映射到的页面。我们在需要身份验证的路由上添加beforeEnter路由守卫。

然后,我们调用createRouter来创建router对象,并调用createWebHashHistory来使用哈希模式。使用哈希模式,主机名和 URL 的其余部分将由#符号分隔。我们还将routes数组添加到传递给createRouter的对象中,以添加路由映射。

然后,我们导出router对象,以便稍后将其添加到我们的应用程序中。

接下来,我们创建登录页面组件。为此,我们创建views文件夹,将Login.vue文件添加到其中,然后编写以下代码:

<template>
  <h1>Admin Login</h1>
  <Form :validationSchema="schema" @submit="submitForm">
    <div>
      <label for="name">Username</label>
      <br />
      <Field name="username" type="text" 
        placeholder="Username" />
      <ErrorMessage name="username" />
    </div>
    <br />
    <div>
      <label for="password">Password</label>
      <br />
      <Field name="password" placeholder="Password" 
        type="password" />
      <ErrorMessage name="password" />
    </div>
    <input type="submit" />
  </Form>
</template>

我们将Form组件添加到validationSchema属性设置为yup模式。我们监听submit事件,当所有字段都有效时会触发该事件,然后点击提交按钮。submitForm方法将包含我们输入的表单字段值,Field组件让我们创建一个表单字段。

ErrorMessage显示带有表单字段的错误消息。如果FieldErrorMessagename属性值匹配,那么任何给定名称的字段的表单验证都将自动显示。placeholder属性让我们添加表单占位符,type属性设置form输入类型。

接下来,我们添加组件的脚本部分。为此,我们编写以下代码:

<script>
import { GraphQLClient, gql } from "graphql-request";
import * as yup from "yup";
import { Form, Field, ErrorMessage } from "vee-validate";
const APIURL = "http://localhost:3000/graphql";
const graphQLClient = new GraphQLClient(APIURL, {
  headers: {
    authorization: "",
  },
});
const schema = yup.object({
  name: yup.string().required(),
  password: yup.string().required(),
});
...
</script>

我们使用GraphQLClient构造函数创建 GraphQL 客户端对象。它接受 GraphQL 端点 URL 和各种我们可以传递的选项。我们将在需要身份验证的组件中传递所需的请求标头。

schema变量保存了yup验证模式,其中包含namepassword字段。两个字段都是字符串类型,都是必填的,如方法调用所示。属性名称必须与FieldErrorMessage组件的name属性值匹配,以便触发字段的验证。

添加登录逻辑并进行第一个 GraphQL 请求

接下来,我们通过编写以下代码添加登录逻辑:

<script>
...
export default {
  name: "Login",
  components: {
    Form,
    Field,
    ErrorMessage,
  },
  data() {
    return {
      schema,
    };
  },
...
        } = await graphQLClient.request(mutation, variables);
        localStorage.setItem("token", token);
        this.$router.push('/orders')
      } catch (error) {
        alert("Login failed");
      }
    },
  },
};
</script>

我们注册了从 VeeValidate 包导入的FormFieldErrorMessage组件。我们有data方法,它返回一个包含模式的对象,以便我们可以在模板中使用它。最后,我们有submitForm方法,用于从Field组件获取usernamepassword值,并进行登录 mutation GraphQL 请求。

我们将$username$password值传递到括号中,以便将它们传递到我们的 mutation 中。这些值将从我们传递给graphQLClient.request方法的variables对象中获取。如果请求成功,我们将从请求中获取令牌。一旦获取到令牌,我们将其放入localStorage.setItem中以将其放入本地存储。

gql标签是一个函数,它让我们将字符串转换为可以发送到服务器的查询 JSON 对象。

如果登录请求失败,我们会显示一个警报。以下截图显示了登录界面:

图 7.2 - 管理员登录界面

图 7.2 - 管理员登录界面

创建订单页面

接下来,我们通过创建views/Orders.vue文件来创建一个订单页面。为此,我们更新以下代码:

<template>
  <TopBar />
  <h1>Orders</h1>
  <div v-for="order of orders" :key="order.order_id">
    <h2>Order ID: {{ order.order_id }}</h2>
    <p>Name: {{ order.name }}</p>
    <p>Address: {{ order.address }}</p>
    <p>Phone: {{ order.phone }}</p>
    <div>
      <h3>Ordered Items</h3>
      <div
        v-for="orderedItems of order.ordered_items"
        :key="orderedItems.shop_item_id"
      >
        <h4>Name: {{ orderedItems.name }}</h4>
        <p>Description: {{ orderedItems.description }}</p>
        <p>Price: ${{ orderedItems.price }}</p>
      </div>
    </div>
    <p>
      <b>Total: ${{ calcTotal(order.ordered_items) }}</b>
    </p>
    <button type="button" @click="deleteOrder(order)">Delete 
      Order</button>
  </div>
</template>

我们添加了TopBar并使用v-for循环遍历订单以渲染条目。我们还循环遍历ordered_items。我们使用calcTotal方法显示了订单物品的总价格。我们还有删除订单按钮,当我们点击它时会调用deleteOrder方法。必须指定key属性,以便 Vue 3 可以识别这些条目。

接下来,我们通过编写以下代码使用 GraphQL 客户端创建脚本:

<script>
import { GraphQLClient, gql } from "graphql-request";
import TopBar from '@/components/TopBar'
const APIURL = "http://localhost:3000/graphql";
const graphQLClient = new GraphQLClient(APIURL, {
  headers: {
    authorization: localStorage.getItem("token"),
  },
});
...
</script>

这与登录页面不同,因为我们将授权标头设置为我们从本地存储中获取的令牌。接下来,我们通过编写以下代码创建组件对象:

<script>
...
export default {
  name: "Orders",
  components: {
    TopBar
...
        {
          getOrders {
            order_id
            name
            address
            phone
            ordered_items {
              shop_item_id
              name
              description
              image_url
              price
            }
          }
        }
      `;
...
      await graphQLClient.request(mutation, variables);
      await this.getOrders();
    },
  },
};
</script>

我们使用components属性注册TopBar组件。我们有data方法返回一个带有orders响应式属性的对象。在beforeMount钩子中,当组件挂载时,我们调用getOrders方法获取订单。calcTotal方法通过使用map从所有orderedItems对象中获取价格,然后调用reduce将所有价格相加来计算所有订单物品的总价格。

getOrders方法发出 GraphQL 查询请求以获取所有订单。我们使用请求指定要获取的字段。我们还指定了要获取的嵌套对象的字段,因此我们也对ordered_items做同样的操作。只有指定的字段才会被返回。

然后,我们使用查询调用graphQlClient.request进行查询请求,并将返回的数据分配给orders响应式属性。

deleteOrder方法接受一个order对象,并向服务器发出removeOrder变更请求。orderId在变量中,因此将删除正确的订单。我们在删除订单后再次调用getOrders以获取最新的订单。

以下截图显示了管理员看到的订单页面:

图 7.3 - 订单页面:管理员视图

图 7.3 - 订单页面:管理员视图

现在我们已经添加了订单页面,接下来我们将添加一个页面,让管理员可以添加和删除他们想要在商店出售的物品。

添加和删除出售物品

接下来,我们添加一个商店物品页面,让我们可以添加和删除商店物品。为此,我们从模板开始。我们通过编写以下代码来渲染商店物品:

<template>
  <TopBar />
  <h1>Shop Items</h1>
  <button @click="showDialog = true">Add Item to Shop</button>
  <div v-for="shopItem of shopItems" 
    :key="shopItem.shop_item_id">
    <h2>{{ shopItem.name }}</h2>
    <p>Description: {{ shopItem.description }}</p>
    <p>Price: ${{ shopItem.price }}</p>
    <img :src="shopItem.image_url" :alt="shopItem.name" />
    <br />
    <button type="button" @click="deleteItem(shopItem)">
      Delete Item from Shop
    </button>
  </div>
  ...
</template>

我们像之前一样添加TopBar组件,并渲染shopItems,就像我们对订单所做的那样。

接下来,我们添加一个带有 HTML 对话框元素的对话框,以便我们可以添加商店物品。为此,我们编写以下代码:

<template>
  ...
  <dialog :open="showDialog" class="center">
    <h2>Add Item to Shop</h2>
    <Form :validationSchema="schema" @submit="submitForm">
      <div>
...
        <Field name="imageUrl" type="text" placeholder=" Image 
          URL" />
        <ErrorMessage name="imageUrl" />
      </div>
      <br />
      <div>
        <label for="price">Price</label>
        <br />
        <Field name="price" type="text" placeholder="Price" />
        <ErrorMessage name="price" />
      </div>
      <br />
      <input type="submit" />
      <button @click="showDialog = false" type="button">
        Cancel</button>
    </Form>
  </dialog>
</template>

我们设置open属性来控制对话框何时打开,并将类设置为center,以便我们可以应用样式将对话框居中并在页面的其余部分上方显示它。

在对话框中,我们以与登录页面相同的方式创建了表单。唯一的区别是表单中的字段。在表单底部,我们有一个取消按钮,将showDialog响应属性设置为false以关闭对话框,因为它被设置为open属性的值。

接下来,我们创建带有 GraphQL 客户端和表单验证模式的脚本(与之前一样),如下所示:

<script>
import { GraphQLClient, gql } from "graphql-request";
import * as yup from "yup";
import TopBar from "@/components/TopBar";
import { Form, Field, ErrorMessage } from "vee-validate";
const APIURL = "http://localhost:3000/graphql";
const graphQLClient = new GraphQLClient(APIURL, {
  headers: {
    authorization: localStorage.getItem("token"),
  },
});
const schema = yup.object({
  name: yup.string().required(),
  description: yup.string().required(),
  imageUrl: yup.string().required(),
  price: yup.number().required().min(0),
});
...
</script>

然后,我们通过编写以下代码来添加组件选项对象:

<script>
... 
export default {
  name: "ShopItems",
  components: {
    Form,
    Field,
    ErrorMessage,
    TopBar,
  },
  data() {
    return {
      shopItems: [],
      showDialog: false,
      schema,
    };
  },
  beforeMount() {
    this.getShopItems();
  },
  ...
};
</script>

我们注册组件并创建一个data方法来返回我们使用的响应属性。beforeMount钩子调用getShopItems方法以从 API 获取商店物品。

接下来,我们通过编写以下代码来添加getShopItems方法:

<script>
... 
export default {
  ...
  methods: {
    async getShopItems() {
      const query = gql`
        {
          getShopItems {
            shop_item_id
            name
            description
            image_url
            price
          }
        }
      `;
      const { getShopItems: data } = await 
        graphQLClient.request(query);
      this.shopItems = data;
    },
    ...
  },
};
</script>

我们只需发出getShopItems查询请求,以获取带有大括号中字段的数据。

接下来,我们通过编写以下代码来添加submitForm方法,以发出变更请求以添加商店物品条目:

<script>
... 
export default {
  ...
  methods: {
    ...
    async submitForm({ name, description, imageUrl, price: 
      oldPrice }) {
      const mutation = gql`
        mutation addShopItem(
          $name: String
          $description: String
          $image_url: String
          $price: Float
        ) {
...
        description,
        image_url: imageUrl,
        price: +oldPrice,
      };
      await graphQLClient.request(mutation, variables);
      this.showDialog = false;
      await this.getShopItems();
    },
    ...
  },
};
</script>

我们通过解构参数中的对象来获取所有表单字段的值,然后调用graphQLClient.request以使用从参数的解构属性中设置的变量进行请求。我们将price转换为数字,因为根据我们在后端创建的模式,price应该是浮点数。

请求完成后,我们将showDialog设置为false以关闭对话框,并再次调用getShopItems以获取商店物品。

我们将添加的最后一个方法是deleteItem方法。该方法的代码可以在以下代码片段中看到:

<script>
... 
export default {
  ...
  methods: {
    ...
    async deleteItem({ shop_item_id: shopItemId }) {
      const mutation = gql`
        mutation removeShopItem($shopItemId: Int) {
          removeShopItem(shopItemId: $shopItemId) {
            status
          }
        }
      `;
      const variables = {
        shopItemId,
      };
      await graphQLClient.request(mutation, variables);
      await this.getShopItems();
    },
    ...
  },
};
</script>

我们发出removeShopItem变更请求,以删除商店物品条目。请求完成后,我们再次调用getShopItems以获取最新数据。

可以在以下截图中看到管理员视图的商店物品页面:

图 7.4 – 商店物品页面:管理员视图

图 7.4 – 商店物品页面:管理员视图

src/App.vue中,我们编写以下代码来添加router-view组件以显示路由组件内容:

<template>
  <router-view></router-view>
</template>
<script>
export default {
  name: "App",
};
</script>

src/main.js中,我们编写以下代码来将路由器添加到我们的应用程序中:

import { createApp } from 'vue'
import App from './App.vue'
import router from '@/plugins/router'
const app = createApp(App)
app.use(router)
app.mount('#app')

最后,在package.json中,我们将服务器脚本更改为从不同端口提供应用程序,以避免与前端发生冲突。为此,我们编写以下代码:

{
  ...
  "scripts": {
    "serve": "vue-cli-service serve --port 8090",
    ...
  },
  ...
}

我们现在已经完成了管理员前端,将继续进行这个项目的最后部分,即为客户创建一个前端,以便他们可以订购商品。

创建客户前端

现在我们已经完成了管理员前端,通过创建客户前端来完成本章的项目。这与管理员前端类似,只是不需要进行身份验证。

我们首先安装与管理员前端相同的包。因此,我们转到frontend文件夹并运行以下命令来安装所有包:

npm i vee-validate@next vue-router@4 yup vuex@4 vuex-persistedstate@ ⁴.0.0-beta.3 graphql graphql-request

我们需要使用Vuex-Persistedstate插件来存储购物车项目。其余的包与管理员前端相同。

创建插件文件夹

我们在src文件夹中创建一个plugins文件夹,并通过在该文件夹中创建router.js文件并编写以下代码来添加路由:

import { createRouter, createWebHashHistory } from 'vue-router'
import Shop from '@/views/Shop'
import OrderForm from '@/views/OrderForm'
import Success from '@/views/Success'
const routes = [
  { path: '/', component: Shop },
  { path: '/order-form', component: OrderForm },
  { path: '/success', component: Success },
]
const router = createRouter({
  history: createWebHashHistory(),
  routes,
})

接下来,我们通过创建src/plugins/vuex.js文件来创建我们的 Vuex 存储,然后编写以下代码:

import { createStore } from "vuex";
import createPersistedState from "vuex-persistedstate";
const store = createStore({
  state() {
    return {
      cartItems: []
    }
  },
  getters: {
    cartItemsAdded(state) {
      return state.cartItems
    }
  },
  mutations: {
    addCartItem(state, cartItem) {
      const cartItemIds = state.cartItems.map(c => 
        c.cartItemId).filter(id => typeof id === 'number')
      state.cartItems.push({
...
      state.cartItems = []
    }
  },
  plugins: [createPersistedState({
    key: 'cart'
  })],
});
export default store

我们调用createStore来创建 Vuex 存储。在传递给createStore的对象中,我们有state方法来返回初始化为数组的cartItems状态。getters属性有一个对象,其中包含cartItemsAdded方法来返回cartItems状态值。

mutations属性对象中,我们有addCartItem方法来调用state.cartItems.pushcartItem值添加到cartItems状态中。我们使用mapfilter方法获取现有的购物车项目 ID。我们只想要数字的 ID。新购物车项目的 ID 将是cartItemIds数组中最高的 ID 加上1

removeCartItem方法让我们调用splice通过索引删除购物车项目,clearCartcartItems状态重置为空数组。

最后,我们将plugins属性设置为一个具有createPersistedState函数的对象,以创建一个Vuex-Persistedstate插件来将cartItems状态存储到本地存储中。key值是存储cartItem值的键。然后,我们导出存储,以便稍后将其添加到我们的应用程序中。

创建订单表单页面

接下来,我们创建一个订单表单页面。这个页面有一个表单,让顾客输入个人信息并编辑购物车。为了创建它,如果还没有,我们创建一个src/views文件夹,然后创建一个OrderForm.vue组件文件。我们首先编写以下模板代码:

<template>
  <h1>Order Form</h1>
  <div v-for="(cartItem, index) of cartItemsAdded" 
    :key="cartItem.cartItemId">
    <h2>{{ cartItem.name }}</h2>
    <p>Description: {{ cartItem.description }}</p>
    <p>Price: ${{ cartItem.price }}</p>
    <br />
...
      <Field name="phone" type="text" placeholder="Phone" />
      <ErrorMessage name="phone" />
    </div>
    <br />
    <div>
      <label for="address">Address</label>
      <br />
      <Field name="address" type="text" placeholder="Address" 
         />
      <ErrorMessage name="address" />
    </div>
    <br />
    <input type="submit" />
  </Form>
</template>

我们有类似于管理员前端的表单。我们使用来自 VeeValidate 的相同FormFieldErrorMessage组件。

我们使用v-for循环遍历购物车项目,将它们渲染到屏幕上。它们通过Vuex-PersistedstatecartItemsAdded getter 从本地存储中检索出来。

接下来,我们通过编写以下代码以相同的方式创建脚本:

<script>
import { GraphQLClient, gql } from "graphql-request";
import { mapMutations, mapGetters } from "vuex";
import { Form, Field, ErrorMessage } from "vee-validate";
import * as yup from "yup";
...
export default {
  name: "OrderForm",
  data() {
    return {
      schema,
    };
  },
  components: {
    Form,
    Field,
    ErrorMessage,
  },
  computed: {
    ...mapGetters(["cartItemsAdded"]),
  },
  ...
};
</script>

我们创建了 GraphQL 客户端和验证模式,并且我们以与管理员前端的商店项目页面相同的方式注册组件。唯一的新事物是调用mapGetters方法,将 Vuex getters 作为组件的计算属性添加进去。我们只需传入一个包含 getter 名称的字符串数组,将计算属性映射到这些 getter。接下来,我们通过编写以下代码添加方法:

<script>
...
export default {  
  ...
  methods: {
    async submitOrder({ name, phone, address }) {
      const mutation = gql`
        mutation addOrder(
          $name: String
          $phone: String
          $address: String
          $ordered_items: [ShopItem]
...
            shop_item_id,
            name,
            description,
            image_url,
            price,,
          })
        ),
      };
      await graphQLClient.request(mutation, variables);
      this.clearCart();
      this.$router.push("/success");
    },
    ...mapMutations(["addCartItem", "removeCartItem", 
        "clearCart"]),
  },
};
</script>

我们有一个submitOrder方法,从订单表单中获取输入的数据,并向服务器发出addOrder变异请求。在variables对象中,我们需要从每个ordered_items对象中删除cartItemId,以使其与我们在后端创建的ShopItem模式匹配。我们不能在发送到服务器的对象中有不包含在模式中的额外属性。

一旦请求成功,我们调用clearCart来清空购物车,然后调用thus.$router.push去到成功页面。mapMutation方法将变异映射到我们组件中的方法。clearCart方法与clearCart Vuex 存储变异相同。

以下截图显示了订单表单的管理员视图:

图 7.5 – 订单表单:管理员视图

图 7.5 – 订单表单:管理员视图

接下来,我们通过编写以下代码创建一个src/views/Shop.vue文件:

<template>
  <h1>Shop</h1>
  <div>
    <router-link to="/order-form">Check Out</router-link>
  </div>
  <button type="button" @click="clearCart()">Clear Shopping 
     Cart</button>
  <p>{{ cartItemsAdded.length }} item(s) added to cart.</p>
  <div v-for="shopItem of shopItems" :key="shopItem.
     shop_item_id">
    <h2>{{ shopItem.name }}</h2>
    <p>Description: {{ shopItem.description }}</p>
    <p>Price: ${{ shopItem.price }}</p>
    <img :src="shopItem.image_url" :alt="shopItem.name" />
    <br />
    <button type="button" @click="addCartItem(shopItem)">Add
       to Cart</button>
  </div>
</template>

我们使用v-for渲染商店项目,就像我们对其他组件所做的那样。我们还有一个router-link组件,在页面上渲染一个链接。

我们使用cartItemsAdded getter 显示添加的购物车项目数量。当我们点击清空购物车时,将调用clearCart Vuex 变异方法。接下来,我们通过编写以下代码为组件添加脚本:

<script>
import { GraphQLClient, gql } from "graphql-request";
import { mapMutations, mapGetters } from "vuex";
const APIURL = "http://localhost:3000/graphql";
const graphQLClient = new GraphQLClient(APIURL);
...
    async getShopItems() {
      const query = gql`
        {
          getShopItems {
            shop_item_id
            name
            description
            image_url
            price
          }
        }
      `;
      const { getShopItems: data } = await 
        graphQLClient.request(query);
      this.shopItems = data;
    },
    ...mapMutations(["addCartItem", "clearCart"]),
  },
};
</script>

我们以相同的方式创建 GraphQL 客户端。在组件中,我们在beforeMount钩子中调用getShopItems来获取购物车商品。我们还调用mapMutations将我们需要的 Vuex 变异映射到组件中的方法。

最后,我们通过编写以下代码将img元素缩小到100px宽度:

<style scoped>
img {
  width: 100px;
}
</style>

接下来,我们通过创建src/views/Success.vue文件并编写以下代码来创建一个订单成功页面:

<template>
  <div>
    <h1>Order Successful</h1>
    <router-link to="/">Go Back to Shop</router-link>
  </div>
</template>
<script>
export default {
  name: "Success",
};
</script>

订单成功页面只有一些文本和一个链接,可以返回到商店的主页。

接下来,在src/App.vue中,我们编写以下代码来添加router-view组件以显示路由页面:

<template>
  <router-view></router-view>
</template>
<script>
export default {
  name: "App",
};
</script>

src/main.js中,我们添加以下代码来将路由器和 Vuex 存储添加到我们的应用程序:

import { createApp } from 'vue'
import App from './App.vue'
import router from '@/plugins/router'
import store from '@/plugins/vuex'
const app = createApp(App)
app.use(router)
app.use(store)
app.mount('#app')

最后,我们通过编写以下代码来更改应用项目的端口:

{
  ...
  "scripts": {
    "serve": "vue-cli-service serve --port 8091",
    ...
  },
  ...
}

我们的项目现在已经完成。

我们可以用npm run serve来运行前端项目,用npm start来运行后端项目。

通过开发购物车项目,我们学会了如何创建 GraphQL API,这些 API 是可以通过查询和变异处理 GraphQL 指令的 JSON API。

总结

我们可以很容易地使用 Express 和express-graphql库创建 GraphQL API。为了更轻松地进行 GraphQL HTTP 请求,我们使用了在浏览器中工作的graphql-request JavaScript GraphQL 客户端。这使我们能够轻松设置请求选项,如标头、要进行的查询和与查询一起使用的变量。

我们使用graphql-request GraphQL 客户端来向后端发出请求,而不是使用常规的 HTTP 客户端。graphql-request库让我们比使用常规的 HTTP 客户端更轻松地进行 GraphQL HTTP 请求。借助它,我们可以轻松地传递带有变量的 GraphQL 查询和变异。

使用映射到解析器函数的模式创建了 GraphQL API。模式让我们定义输入和输出数据的所有数据类型,这样我们就不必猜测要发送哪些数据。如果发送任何无效数据,我们将收到一个错误,告诉我们请求出了什么问题。我们还必须指定我们想要在 GraphQL 查询中返回的数据字段,只有我们指定的字段才会返回。这使我们能够返回我们需要使用的数据,使其更加高效。

此外,我们可以在向/graphql端点发出请求之前进行常规令牌检查,为 GraphQL API 请求添加身份验证。

我们可以使用 GraphiQL 交互式沙盒轻松测试 GraphQL 请求,让我们可以发出想要的请求。要测试经过身份验证的请求,我们可以使用ModHeader扩展来设置标头,以便我们可以成功地进行经过身份验证的请求。

在下一章中,我们将看看如何使用 Laravel 和 Vue 3 创建实时聊天应用程序。

第八章:使用 Vue 3、Laravel 和 Socket.IO 构建聊天应用

在之前的章节中,我们创建了仅通过 HTTP 通信的前端项目或全栈项目。前端和后端之间没有实时通信。如果我们需要从服务器端实时向客户端通信数据,或者反过来,实时通信有时是必要的。没有一些实时通信机制,就没有办法在客户端不发起请求的情况下从服务器端向客户端通信。这是我们可以很容易通过 Laravel 框架和 Socket.io 添加的功能。

在本章中,我们将研究以下主题:

  • 使用 Laravel 创建 API 端点

  • 设置 JWT 身份验证

  • 创建前端以让用户聊天

Laravel 是用 PHP 编写的后端 Web 框架。它是一个包括处理 HTTP 请求、数据库操作和实时通信的全面后端框架。

在本章中,我们将看看如何让所有这些部分一起工作,以便我们可以使用 Vue 3、Laravel Echo Server 和 Redis 一起创建一个聊天应用。

技术要求

要完全理解本章,需要以下内容:

  • 对 PHP 的基本理解

  • 使用 Vue 组件创建基本应用的能力

  • 使用 Axios HTTP 客户端发送和接收 HTTP 请求的能力

本章项目的代码可在github.com/PacktPublishing/-Vue.js-3-By-Example/tree/master/Chapter08找到。

使用 Laravel 创建 API 端点

创建我们的聊天应用的第一步是使用 Laravel 创建后端应用。使用 Laravel 创建 API 是我们必须学习的主要内容。这是我们以前没有做过的事情。这也意味着我们必须使用 PHP 编写代码,因为 Laravel 是基于 PHP 的 Web 框架。因此,在阅读此代码之前,您应该学习一些基本的 PHP 语法。与 JavaScript 和其他面向对象的语言一样,它们共享类似的概念,如使用对象、数组、字典、循环、类和其他基本的面向对象编程概念。因此,在学习难度方面,它与 JavaScript 应该没有太大的不同。

安装所需的库

要使用 Laravel 创建我们的 API,我们不必自己创建所有文件,只需运行几个命令,就会自动为我们创建所有文件和配置设置。在创建 API 之前,我们必须运行 PHP。在 Windows 中,将 PHP 添加到我们的 Windows 安装的最简单方法是使用 XAMPP。我们可以通过访问www.apachefriends.org/download.html来下载和安装它。它也适用于 macOS 和 Linux。

安装完成后,我们可以使用Composer创建我们的 Laravel API。Composer 是 PHP 的包管理器,我们将在以后使用它来安装更多的库。创建项目的最简单方法是创建我们的项目文件夹,然后在转到文件夹后运行创建 Laravel 项目的命令:

  1. 首先,我们创建一个名为vue-example-ch8-chat-app的项目文件夹,其中将分别放置前端和后端的文件夹。

  2. 然后,在这个文件夹中,我们创建后端文件夹来存放我们的 Laravel 项目代码文件。

  3. 现在我们转到命令行,然后进入vue-example-ch8-chat-app,然后运行composer global require laravel/installer

这将安装 Laravel 安装程序,让我们创建 Laravel 项目。全局库的位置如下:

  • macOS: $HOME/.composer/vendor/bin

  • Windows: %USERPROFILE%\AppData\Roaming\Composer\vendor\bin

  • GNU / Linux 发行版: $HOME/.config/composer/vendor/bin$HOME/.composer/vendor/bin

我们还可以运行composer global about来查找库文件的位置。

完成后,我们使用一个命令创建包含所有文件和配置文件的脚手架,并为我们安装所有所需的库。

我们通过命令行进入vue-example-ch8-chat-app文件夹,然后运行laravel new backend在后端文件夹中创建 Laravel 应用程序。Laravel 安装程序将运行并为我们的 Laravel 创建脚手架。此外,Composer 将安装我们运行 Laravel 所需的所有 PHP 库。完成所有这些后,我们应该拥有一个完整的 Laravel 安装,其中包含我们运行应用程序所需的所有文件和配置。

创建数据库和迁移文件

现在,随着 Laravel 应用程序的创建和所有相关库的安装,我们可以开始在 Laravel 应用程序上创建我们的 API。首先,我们通过创建一些迁移文件来创建我们的数据库。我们需要它们来创建chatsmessages表。chats表包含聊天室数据。而messages表包含与聊天室相关联的聊天消息。它还将引用发送消息的用户。

我们不需要创建users表,因为在创建 Laravel 应用程序时会自动创建。几乎每个应用程序都需要保存用户数据,因此这是自动包含的。使用 Laravel 脚手架,我们可以创建具有用户名、电子邮件和密码的用户,并使用刚刚创建的用户的用户名和密码登录。Laravel 还具有发送电子邮件进行用户验证的功能,而无需添加任何代码。

创建迁移,我们运行以下命令:

php artisan make:migration create_chats_table
php artisan make:migration create_messages_table

上述命令将为我们创建带有日期和时间前缀的迁移文件。所有迁移文件都在database/migrations文件夹中。因此,我们可以进入此文件夹并打开文件。在文件名为create_chats_table的文件中,我们添加以下代码:

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateChatsTable extends Migration
{
    public function up()
    {
        Schema::create('chats', function (Blueprint $table)
        {
            $table->id();
            $table->string('name');
            $table->timestamp('created_at')->useCurrent();
            $table->timestamp('updated_at')->useCurrent();
        });
    }
    public function down()
    {
        Schema::dropIfExists('chats');
    }
}

上述代码将创建chats表。up()方法包含我们在运行迁移时要运行的代码。down()方法包含我们在要撤消迁移时要运行的方法。

up()方法中,我们调用Schema::create来创建表。::符号表示该方法是静态方法。第一个参数是表名,第二个参数是一个回调函数,我们在其中添加创建表的代码。$table对象具有id()方法来创建id列。string()方法创建一个带有参数中列名的string列。timestamp()方法让我们创建一个带有给定列名的timestamp列。useCurrent()方法让我们将时间戳的默认值设置为当前日期和时间。

down()方法中,我们有Schema::dropIfExists()方法来删除具有参数中给定名称的表,如果存在的话。

迁移文件必须具有从Migration类继承的类,才能用作迁移。

同样,在文件名为create_message_table的迁移文件中,我们编写以下内容:

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateMessagesTable extends Migration
{
    public function up()
    {
        Schema::create('messages', function (Blueprint 
         $table) {
            $table->id();
            $table->unsignedBigInteger('user_id');
            $table->unsignedBigInteger('chat_id');
            $table->string('message');
            $table->timestamp('created_at')->useCurrent();
            $table->timestamp('updated_at')->useCurrent();
            $table->foreign('user_id')->references('id')-
              >on('users');
            $table->foreign('chat_id')->references('id')-
              >on('chats');
        });
    }
    public function down()
    {
        Schema::dropIfExists('messages');
    }
}

上述文件中有创建messages表的代码。这个表有更多的列。我们有与chats表中相同的idtimestamp列,但我们还有user_id无符号integer列来引用发布消息的用户的 ID,以及chat_id无符号integer列来引用chats表中的条目,将消息与创建它的聊天会话关联起来。

foreign()方法让我们指定user_idchat_id列分别引用用户和chats表中的内容。

配置我们的数据库

在我们运行迁移之前,我们必须配置我们将用于存储后端数据的数据库。为此,我们通过复制.env.example文件并将其重命名为.env来在项目的root文件夹中创建.env文件。

.env文件有许多设置,我们需要运行我们的 Laravel 应用程序。为了配置我们将使用的数据库,我们运行以下命令,以便连接到 SQLite 数据库:

DB_CONNECTION=sqlite
DB_DATABASE=C:\vue-example-ch8-chat-app\backend\db.sqlite

完整的配置文件在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/.env.example。我们只需将其内容复制到同一文件夹中的.env文件中以使用该配置。

本章中,我们使用 SQLite 是为了简化,这样我们就可以专注于使用 Vue 3 创建聊天应用程序。然而,如果我们要构建生产应用程序,我们应该使用具有更好安全性和管理能力的生产级数据库。DB_CONNECTION环境变量具有我们想要使用的数据库类型,即 SQLite。在DB_DATABASE设置中,我们指定了数据库文件的绝对路径。Laravel 不会自动为我们创建这个文件,所以我们必须自己创建。要创建 SQLite 文件,我们可以使用 DB Browser for SQLite 程序。它支持 Windows、macOS 和 Linux,因此我们可以在所有流行的平台上运行它。您可以从sqlitebrowser.org/dl/下载该程序。安装完成后,只需在左上角点击New Database,然后点击File菜单,再点击Save以保存数据库文件。

配置到 Redis 的连接

除了将 SQLite 用作我们应用程序的主要数据库之外,我们还需要配置与 Redis 的连接,以便我们可以使用 Laravel 的排队功能将数据广播到 Redis 服务器,然后由 Laravel Echo 服务器接收,以便事件将被发送到 Vue 3 前端。 Redis 配置的环境变量如下:

BROADCAST_DRIVER=redis
QUEUE_CONNECTION=redis
QUEUE_DRIVER=sync

然后我们添加以下 Redis 配置:

REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

第一组环境变量配置了队列将数据发送到的位置。BROADCAST_DRIVER设置为redis,以便我们将事件发送到 Redis。QUEUE_CONNECTION也必须设置为redis,出于同样的原因。QUEUE_DRIVER设置为sync,以便事件在广播后立即发送到队列。

运行迁移文件

现在我们已经创建了迁移并配置了要使用的数据库,我们运行php artisan migrate来运行迁移。运行迁移将向我们的 SQLite 数据库添加表。添加表后,我们可以添加种子数据,这样当我们想要重置数据库或者数据库为空时,我们就不必重新创建数据。要创建种子数据,我们在database/seeders/DatabaseSeeder.php文件中添加一些代码。在文件中,我们编写以下代码来添加我们数据库的文件:

<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use App\Models\User;
use App\Models\Chat;
class DatabaseSeeder extends Seeder
{
    public function run()
    {
        $this->addUsers();
        $this->addChats();
        $this->addMessages();
    }
    private function addUsers()
    {
        for ($x = 0; $x <= 1; $x++) {
            DB::table('users')->insert([
                'name' => 'user'.$x,
                'email' => 'user'.$x.'@gmail.com',
                'password' => Hash::make('password'),
            ]);
        }
    }
  ...
}

我们有addUsers()方法将一些用户添加到users表中。我们创建一个循环,调用DB::table('users')->insertusers表中插入一些条目。->符号与 JavaScript 中的句号相同。它允许我们访问对象属性或方法。

insert()方法中,我们传入一个带有我们要插入的键和值的关联数组或字典:

    ...
    private function addChats()
    {
        for ($x = 0; $x <= 1; $x++) {
            DB::table('chats')->insert([
                'name' => 'chat '.$x,
            ]);
        }
    }
    ...

addChats()方法让我们添加聊天室条目。我们只需要插入名称。在addMessages()方法中,我们插入messages表的条目。我们获取要设置为id值的用户条目的值,该值来自users表中的现有条目。同样,我们对chat_id执行相同的操作,通过从chats表中获取条目并使用该条目的id值将其设置为chat_id的值。

...
    private function addMessages()
    {
        for ($x = 0; $x <= 1; $x++) {
            DB::table('messages')->insert([
                'message' => 'hello',
                'user_id' => User::all()->get(0)->id,
                'chat_id' => Chat::all()->get($x)->id
            ]);
            DB::table('messages')->insert([
                'message' => 'how are you',
                'user_id' => User::all()->get(1)->id,
                'chat_id' => Chat::all()->get($x)->id
            ]);
        }
    }
...

一旦我们编写了 seeder,我们可能希望重新生成 Composer 的自动加载程序,以便使用我们的依赖项更新自动加载程序。我们可以通过运行composer dump-autoload来做到这一点。这在引用任何依赖项过时并且我们想要刷新引用以使其不过时时非常方便的。然后我们运行php artisan db:seed来运行 seeder 以将所有数据填充到表中。

要将数据重置为原始状态,我们可以通过运行php artisan migrate:refresh –seed来同时运行迁移和 seeder。我们也可以只清空数据库并重新运行所有迁移,通过运行php artisan migrate:refresh

创建我们的应用逻辑

现在我们已经有了数据库结构和种子数据,我们可以继续创建我们的应用逻辑。我们创建一些控制器,这样我们就可以从前端接收请求。Laravel 控制器应该在app/Http/Controllers文件夹中。我们创建一个用于接收请求或操作chats表的控制器,另一个用于接收请求来操作messages表。Laravel 自带一个用于创建控制器的命令。首先,我们通过运行以下代码创建ChatController.php文件:

php artisan make:controller Chat

然后我们应该将app/Http/Controllers/ChatController.php文件添加到我们的项目中。完整的代码在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Http/Controllers/ChatController.php

一个 Laravel 控制器有一个继承自Controller类的类。在这个类里,我们有一些方法,这些方法将被映射到 URL,这样我们就可以运行这些方法并做我们想做的事情。每个方法都接受一个请求对象,其中包括请求数据,包括头部、URL 参数和主体。

get()方法查找单个Chat条目。Chatchats表的模型类。在 Laravel 中,约定是类名对应于表名,通过去掉末尾的s然后将第一个字母转换为大写来转换表名。因此,Chat模型类用于操作chats表中的条目。Laravel 会自动进行映射,因此我们不必自己做任何事情。我们只需要记住这个约定,这样我们就不会被搞混。find()方法是一个我们用来通过 ID 获取单个条目的static方法。

在所有控制器函数中,我们可以返回一个字符串,一个关联数组,一个响应对象,或者从query()方法返回的结果作为响应返回。因此,当我们发出请求并调用get方法时,Chat::find方法的返回值将作为响应返回。

getAll()方法用于从chats表中获取所有条目。all()方法是一个静态方法,返回所有条目。

create()方法用于从请求数据创建条目。我们调用Validate::make静态方法为请求数据创建验证器。第一个参数是$request->all(),这是一个返回请求对象中所有项目的方法。第二个参数是一个关联数组,其中包含要验证的请求体的键。它的值是一个包含验证规则的字符串。required规则确保name被填写。string规则检查作为name键值的值是否为字符串。max:255规则是name值中我们可以拥有的最大字符数:

...
    public function create(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'name' => 'required|string|max:255',
        ]);
        if($validator->fails()){
            return response()->json($validator->errors()-
              >toJson(), 400);
        }
        $chat = Chat::create([
            'name' => $request->get('name'),
        ]);
        return response()->json($chat, 201);
    }
...

我们使用$validator->fails()方法来检查验证是否失败。$validator是由Validator::make方法返回的对象。在if块中,我们调用response()->json()将错误以 400 状态码返回给用户。

否则,我们调用Chat::create来创建chats表条目。我们使用$request->get方法从请求体中获取name字段的值,使用我们想要获取的键。然后我们将其设置为我们传递给create方法的关联数组中'name'键的值。

我们对update()方法做类似的操作,只是我们调用Chat::find来通过其id值找到项目。然后我们将请求体中的name字段的值分配给返回的聊天对象的name属性。然后我们调用$chat->save()来保存最新值。然后我们通过调用response()->json($chat)返回响应,以将最新的聊天条目转换为 JSON:

...
    public function update(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'name' => 'required|string|max:255',
        ]);
        if($validator->fails()){
            return response()->json($validator->errors()-
              >toJson(), 400);
        }
        $chat = Chat::find($request->id);
        $chat->name =  $request->get('name');
        $chat->save();
        return response()->json($chat);
    }
...

当我们向 API 发出DELETE请求以删除聊天室条目时,将调用delete()方法。我们再次调用Chat::find来查找具有给定 ID 的chats表中的条目。然后我们调用$chat->delete()来删除返回的条目。然后我们返回一个空响应:

...
    public function delete(Request $request)
    {
        $chat = Chat::find($request->id);
        $chat->delete();
        return response(null, 200);
    }
...

我们有类似的逻辑MessageController.php,让我们保存聊天消息。我们有UserController.php文件,其中包含注册用户帐户时保存用户数据的代码。

重要:

这些文件可以在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Http/Controllers/MessageController.phpgithub.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Http/Controllers/UserController.php找到。

公开控制器方法以供端点使用

接下来,我们必须将我们的控制器方法映射到我们将发出请求调用的 URL。我们通过向routes/api.php文件添加一些代码来实现这一点。为此,我们用以下代码替换文件中的内容:

<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\ChatController;
use App\Http\Controllers\MessageController;
Route::post('register', [UserController::class, 'register']);
Route::group([
    'middleware' => 'api',
    'prefix' => 'auth'
], function () {
    Route::post('login', [AuthController::class, 'login']);
    Route::post('logout', [AuthController::class, 
     'logout']);
    Route::post('refresh', [AuthController::class, 
     'refresh']);
    Route::post('me', [AuthController::class, 'me']);
     });
...
    Route::get('{chat_id}', [MessageController::class, 
     'getAll']);
    Route::post('create', [MessageController::class, 
     'create']);
});

我们通过分别调用Route::postRoute::get方法,将控制器方法公开为客户端的 POST 和 GET 端点。

jwt.verify中间件是我们在运行路由的controller方法之前用来检查 JSON Web 令牌的方法。这样,只有在令牌有效时才会运行controller()方法。

然后我们必须创建AuthController来进行 JSON Web 令牌身份验证。

首先,我们运行以下命令:

php artisan make:controller AuthController

然后在app/Http/Controllers/AuthController.php文件中,我们添加了用于获取当前用户数据、登录和注销的端点方法。该文件的代码位于github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Http/Controllers/AuthController.php

如果您没有app/Http/Middleware/JwtMiddleware.php文件,该文件的完整代码位于github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Http/Middleware/JwtMiddleware.php

它使我们能够在 Vue 客户端和此应用程序之间启用 JSON Web 令牌身份验证。

设置 JWT 身份验证

现在,我们必须设置 JSON Web 令牌身份验证与我们的 Laravel 应用程序,以便我们可以将我们的前端与后端分开托管。为此,我们使用了tymon/jwt-auth库。要安装它,我们运行以下命令:

composer require tymon/jwt-auth

接下来,我们运行以下命令来发布软件包配置文件:

php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"

上述命令将为我们添加所有必需的配置。现在我们应该在我们的应用程序中添加config/jwt.php。然后,我们通过运行以下命令生成秘密密钥以签署 JSON Web 令牌:

php artisan jwt:secret

secret密钥将被添加到.env文件中,密钥为JWT_SECRET

配置我们的身份验证

接下来,我们必须配置我们的身份验证,以便在成功发出需要身份验证的路由请求之前,我们可以验证我们的 JSON Web 令牌。在config/auth.php中,我们有以下代码:

<?php
return [
    'defaults' => [
        'guard' => 'api',
        'passwords' => 'users',
    ],
    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'api' => [
            'driver' => 'jwt',
            'provider' => 'users',
        ],
    ],
    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
    ],
    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_resets',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],
    'password_timeout' => 10800,
];

guards部分,我们有一个api密钥,其值为一个关联数组,其中驱动程序键设置为'jwt',提供者设置为'users',以便我们使用jwt-auth库发行的 JSON Web 令牌对用户进行身份验证。

接下来,我们添加代码以启用 CORS,以便我们的 Vue.js 3 应用程序可以与其通信。

启用跨域通信

为了使我们能够在前端和后端之间进行跨域通信,我们安装了fruitcake/laravel-cors软件包。为此,我们运行以下命令:

composer require fruitcake/laravel-cors

然后,在app/Http/Kernel.php中,我们应该有以下内容:

<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
    protected $middleware = [
        \App\Http\Middleware\TrustProxies::class,
        \Fruitcake\Cors\HandleCors::class,
        \App\Http\Middleware\
           PreventRequestsDuringMaintenance::class,
        \Illuminate\Foundation\Http\Middleware\
           ValidatePostSize::class,
        \App\Http\Middleware\TrimStrings::class,
        \Illuminate\Foundation\Http\Middleware\
           ConvertEmptyStringsToNull::class,
        \Fruitcake\Cors\HandleCors::class,
    ];
...
        'password.confirm' =>  \Illuminate\Auth\Middleware\
           RequirePassword::class,
        'signed' => \Illuminate\Routing\Middleware\
           ValidateSignature::class,
        'throttle' => \Illuminate\Routing\Middleware\
           ThrottleRequests::class,
        'verified' => \Illuminate\Auth\Middleware\
           EnsureEmailIsVerified::class,
        'jwt.verify' => \App\Http\Middleware\
           JwtMiddleware::class,
    ];
}

我们在$routesMiddleware关联数组中写入以下代码,注册了laravel-cors包中提供的HandleCors中间件,并在$routeMiddleware关联数组中注册了jwt.verify中间件:

'jwt.verify' => \App\Http\Middleware\JwtMiddleware::class,

这样,我们可以使用jwt.verify中间件来验证令牌。

完整的代码在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Http/Kernel.php

此外,我们安装predis包,这样我们就可以更轻松地与我们的 Redis 数据库进行通信。要安装predis包,我们运行以下命令:

composer require predis/predis

然后,在config/database.php中,我们编写以下代码:

<?php
use Illuminate\Support\Str;
return [
    ...    'redis' => [
        'client' => env('REDIS_CLIENT', 'predis'),
        'options' => [
            'cluster' => env('REDIS_CLUSTER', 'redis'),
            'prefix' => env('REDIS_PREFIX', Str::slug(   
            env('APP_NAME', 'laravel'), '_').'_database_'),
        ],
        ...
        'cache' => [
            'url' => env('REDIS_URL'),
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => env('REDIS_PORT', '6379'),
            'database' => env('REDIS_CACHE_DB', '1'),
        ],
    ],
];

我们在关联数组中配置了 Redis 数据库连接,以便我们可以连接到 Redis。

完整的文件在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/config/database.php

现在我们已经为存储用户数据和他们的聊天消息创建了 API,我们将继续为 Laravel 应用程序添加实时通信功能,以便用户可以实时保存和获取聊天消息。

添加实时通信

现在我们已经完成了添加路由、身份验证和数据库配置和操作代码,我们准备添加让我们在前端和后端实时通信的代码。首先,我们需要在 Laravel 后端创建一个event类,这样我们就可以调用event函数来广播事件,就像我们在MessageController中所做的那样。

为此,我们运行php artisan make:event MessageSent命令来创建MessageSent事件类。该类现在应该在backend/app/Events/MessageSent.php文件中。创建文件后,我们用以下代码替换文件中的内容:

<?php
namespace App\Events;
...
class MessageSent implements ShouldBroadcast
{
    use InteractsWithSockets, SerializesModels;
    public $user;
    public $message;
    public function __construct(User $user, Message 
      $message)
    {
        $this->user = $user;
        $this->message = $message;
    }
    public function broadcastOn()
    {
        return new Channel('chat');
    }
    public function broadcastAs()
    {
        return 'MessageSent';
    }
}

__constructor()方法是构造函数;我们获取$user$message对象,然后将它们分配给同名的类变量。broadcastOn()方法返回Channel对象,它创建一个我们可以在前端监听的频道。broadCastAs()方法返回我们在聊天频道中监听的事件名称。我们将在前端使用这个来监听广播事件。一个event类应该实现ShouldBroadcast接口,以便可以从中广播事件。

MessageSent.php的完整代码位于github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/app/Events/MessageSent.php

backend/routes/channels.php文件中,我们应该有以下代码,以便所有用户都可以监听聊天频道:

<?php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('chat', function () {
    return true;
});

第一个参数是我们要订阅的频道的名称。回调是一个函数,如果用户可以监听事件则返回true,否则返回false。一个可选参数是user对象,以便我们可以检查用户是否可以监听给定事件。

此文件的完整代码位于github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/backend/routes/channels.php

通信流程如下图所示:

图 8.1 - 聊天应用程序架构图

图 8.1 - 聊天应用程序架构图

Vue 3 前端通过 HTTP 请求将要发送的消息发送到 Laravel 应用程序。Laravel 应用程序将消息保存到messages表中,并带有聊天会话和用户的 ID。它还通过队列向 Redis 服务器广播事件。然后 Laravel Echo 服务器监视 Redis,看是否有任何内容保存到 Redis 数据库。如果有任何新保存的内容,那么 Laravel Echo 服务器将其推送到 Vue 3 前端。Vue 3 前端通过使用 Laravel Echo 客户端和 Socket.IO 客户端监听 Laravel Echo 服务器上的事件来接收它。

与 Socket.IO 通信

为了使我们的后端应用通过 Socket.IO 与前端通信,我们需要使用 Laravel Echo Server。为此,我们首先需要全局安装 Laravel Echo Server 的npm包。我们可以通过运行npm install –g laravel-echo-server来安装它。然后我们将运行这个包来创建配置文件以设置通信。

为此,我们创建一个新文件夹,然后运行laravel-echo-server init来运行命令行向导,在文件夹中创建 Laravel Echo Server 配置文件。在这一点上,我们可以用默认设置回答所有问题。这是因为一旦向导完成,我们将编辑它创建的配置文件。

向导完成后,我们应该在文件夹中看到laraval-echo-server.json文件。现在我们打开它,并用以下代码替换其中的内容:

{
 "authHost": "http://localhost:8000",
 "authEndpoint": "/broadcasting/auth",
 "clients": [
  {
   "appId": "APP_ID",
   "key": "c84077a4dabd8ab2a60e51b051c9d0ea"
  }
 ...
  },
  "sqlite": {
   "databasePath": "/database/laravel-echo-server.sqlite"
  },
  "publishPresence": true
 },
 "devMode": true,
 "host": "127.0.0.1",
 ...
 "http": true,
  "redis": true
 },
 "apiOriginAllow": {
  "allowCors": true,
  "allowOrigin": "*",
  "allowMethods": "GET, POST",
  "allowHeaders": "Origin, Content-Type, X-Auth-Token,     X-Requested-With, Accept, Authorization, X-CSRF-TOKEN,     X-Socket-Id"
 }
}

在上述代码中,我们有配置的 JSON 代码,以便 Laravel Echo Server 可以监听保存在 Redis 中的项目,然后通过 Socket.IO 客户端将 Redis 数据库中的内容发送到前端。devMode属性设置为true,以便我们可以看到所有发送的事件。主机具有 Laravel Echo Server 的主机 IP 地址。port属性设置为6001,因此此服务器将监听 6001 端口。此文件的另一个重要部分是apiOriginAllow属性。它设置为一个对象,其中allowCors设置为true,以便我们可以与前端进行跨域通信。

allowOrigin属性让我们设置允许监听发出的事件的域。allowMethods属性具有允许从前端接收的 HTTP 方法。allowHeaders属性具有允许从前端发送到 Laravel Echo Server 的 HTTP 请求标头列表。

authHost具有 Laravel 应用的基本 URL,以便它可以监听 Laravel 应用广播的事件。authEndpoint具有用于检查用户是否经过身份验证以便监听需要身份验证的事件的身份验证端点。

这个配置文件的另一个重要部分是数据库配置属性。数据库属性设置为"redis",以便它将监听 Redis 服务器以保存项目。databaseConfig属性具有设置,让我们连接到 Redis 服务器。"redis"属性设置为一个对象,其中port属性设置为 Redis 服务器监听的端口。Redis 的默认端口是6379host属性是 Redis 服务器的位置。publishPresence属性设置为true,以便 Redis 发布保存在其数据库中的项目。

完整的配置在github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter08/laravel-echo-server/laravel-echo-server.json

Redis 的最新版本仅适用于 Linux 或 macOS。要在 Ubuntu Linux 上安装 Redis,请运行以下命令安装 Redis 服务器:

sudo apt update
sudo apt install redis-server

如果您使用的是 Windows 10,可以使用 Windows 子系统来安装 Ubuntu Linux 的副本,以便运行最新版本的 Redis。要在 Windows 10 上安装 Ubuntu,请执行以下操作:

  1. 开始菜单中键入打开或关闭 Windows 功能

然后我们滚动到底部,点击Windows 子系统用于 Linux进行安装。它会要求您重新启动,您应该在继续之前这样做。

  1. 计算机重新启动后,转到Windows 商店,搜索Ubuntu,然后您可以点击它并点击获取

  2. 安装完成后,您可以在开始菜单中键入Ubuntu并启动它。现在只需按照说明完成安装。

然后您可以运行上述两个命令来安装 Redis。

安装完 Redis 后,我们运行以下命令来运行 Redis 服务器:

redis-server

现在我们项目的后端部分已经完成。现在我们运行php artisan servephp artisan queue:listen来运行 Laravel 应用程序和队列工作程序。我们还必须运行 Laravel Echo Server,通过运行laravel-echo-server start来启动 Laravel Echo Server。

如果遇到任何问题,您可能需要清除缓存以确保最新的代码实际在运行。要做到这一点,您可以运行以下命令来清除所有缓存:

php artisan config:cache
php artisan config:clear
php artisan route:cache
php artisan route:clear

如果缓存已清除,代码仍然无法正常工作,那么您可以返回检查代码。

现在我们已经为我们的 Laravel 应用程序添加了实时通信,我们准备继续创建前端,让用户注册帐户,登录并在聊天室中开始聊天。

创建前端以让用户聊天

现在我们已经完成并运行了后端代码,我们可以开始工作在前端。前端与前几章的内容并没有太大的不同。我们使用 Vue CLI 在vue-example-ch8-chat-app文件夹的frontend文件夹中创建我们的项目,然后我们可以开始编写我们的代码。

在“vue-example-ch8-chat-app / frontend”文件夹中,我们运行vue create,然后选择选择版本,然后选择启用Vue Router选项的Vue 3选项。一旦 Vue CLI 向导完成运行,我们就可以开始构建我们的前端。

安装 Vue 依赖项

除了 Vue 依赖项之外,我们还需要安装 Axios HTTP 客户端、Socket.IO 客户端和 Laravel Echo 客户端包,以通过 Laravel Echo 服务器分别进行 HTTP 请求和监听从服务器端发出的事件。要安装这些,我们运行以下命令:

npm install axios socket.io-client laravel-echo

首先,在src文件夹中,我们创建constants.js文件并添加以下代码:

export const APIURL = 'http://localhost:8000';

我们添加了APIURL常量,当我们向 API 端点发出请求时将使用它。在src/main.js中,我们用以下代码替换我们已有的代码:

...
axios.interceptors.request.use((config) => {
  if (config.url.includes('login') || 
   config.url.includes('register')) {
    return config;
  }
  return {
    ...config, headers: {
      Authorization: `Bearer ${localStorage.getItem('token')}`,
    }
  }
}, (error) => {
  return Promise.reject(error);
});
axios.interceptors.response.use((response) => {
  const { data: { status } } = response;
  if (status === 'Token is Expired') {
    router.push('/login');
  }
  return response;
}, (error) => {
  return Promise.reject(error);
});
createApp(App).use(router).mount('#app')

在这个文件中有两件新事物。我们有 Axios 请求和响应拦截器,这样我们就可以在每次请求时应用相同的设置,而不必重复相同的代码。axios.interceptors.request.use()方法接受一个回调,让我们根据需要返回一个新的config对象。

如果请求的 URL 不包括“登录”或“注册”,那么我们需要将令牌添加到Authorization标头中。这就是我们在传递给“use()”方法的回调中所做的。我们将令牌添加到需要它们的端点的请求配置中。第二个回调是一个错误处理程序,我们只是返回一个拒绝的承诺,这样我们就可以在发出请求时处理它们。

类似地,我们有axios.interceptor.response.use()方法来检查每个响应,并使用第一个参数中的回调函数。我们检查响应体是否具有将status属性设置为"Token is expired"字符串,以便在收到此消息时重定向到登录页面并返回响应。否则,我们原样返回响应。第二个参数中的错误处理程序与请求拦截器相同。

创建我们的组件

接下来,我们创建我们的组件。我们从表单开始,让我们设置或编辑聊天室名称。为此,我们进入src/components文件夹并创建ChatroomForm.vue文件。然后,在文件中,我们编写以下代码:

<template>
  <div>
    <h1>{{ edit ? "Edit" : "Add" }} Chatroom</h1>
    <form @submit.prevent="submit">
      <div class="form-field">
        <label for="name">Name</label>
        <br />
        <input v-model="form.name" type="text" name="name" 
          />
      </div>
      <div>
        <input type="submit" />
      </div>
    </form>
  </div>
</template>
...

该组件接受edit属性,其类型为布尔值,以及id属性,其类型为字符串。它有一个响应式属性,即form属性。它用于将输入值绑定到响应式属性。我们有submit()方法来检查名称是否已填写。如果是,则我们继续提交。如果edit属性为 true,则我们进行 PUT 请求以更新chats表中具有给定 ID 的现有条目。否则,我们在相同的表中创建一个具有给定名称值的新条目。完成后,我们重定向到具有聊天室列表的主页。

created钩子中,我们检查edit响应式属性是否为 true。如果是,则我们获取具有给定 ID 的chats表中的条目,并将其设置为form响应式属性的值,以便我们可以在输入框中看到form.name属性的值:

<script>
import axios from "axios";
import { APIURL } from "../constants";
export default {
  name: "ChatroomForm",
  ...
  async created() {
    if (this.edit) {
      const { data } = await 
       axios.get(`${APIURL}/api/chat/${this.id}`);
      this.form = data;
    }
  },
 };
</script>

接下来,在src/components文件夹中,我们创建NavBar.vue来创建一个渲染导航栏的组件。在文件内,我们编写以下代码:

<template>
  <div>
    <ul>
      <li>
        <router-link to="/">Chatrooms</router-link>
      </li>
      <li><a href="#" @click="logOut">Logout</a></li>
    </ul>
  </div>
</template>
<script>
import axios from "axios";
import { APIURL } from "../constants";
export default {
  name: "NavBar",
  methods: {
    async logOut() {
      await axios.post(`${APIURL}/api/auth/logout`);
      localStorage.clear();
      this.$router.push("/login");
    },
  },
};
</script>
...

我们有一个router-link组件,可以转到聊天室页面。这是通过将to属性设置为/路由来实现的。我们还有一个链接,当我们点击它时调用logout()方法。logout()方法发出 POST 请求到/api/auth/logOut端点来使 JSON web token 失效。然后我们调用localStorage.clear()方法来清除本地存储。然后我们调用this.$router.push来重定向到登录页面。

在样式部分,我们为ulli元素设置了一些样式,使li水平显示,并在它们之间设置了一些边距。我们还将list-style-type属性设置为none,以便从列表中移除项目符号:

<style scoped>
ul {
  list-style-type: none;
}
ul li {
  display: inline;
  margin-right: 10px;
}
</style>

src/views文件夹中,我们有页面组件,我们使用 Vue Router 将其映射到 URL,以便我们可以从浏览器访问这些组件。首先,在src/views文件夹中创建AddChatroomForm.vue组件文件,并添加以下代码:

<template>
  <div>
    <NavBar></NavBar>
    <ChatroomForm></ChatroomForm>
  </div>
</template>
<script>
import ChatroomForm from "@/components/ChatroomForm";
import NavBar from "@/components/NavBar";
export default {
  components: {
    ChatroomForm,
    NavBar
  },
};
</script>

我们只需在components属性中注册NavBarChatroomForm组件,然后将它们添加到模板中。

接下来,我们创建ChatRoom.vue组件来显示我们的聊天消息,并在此文件中添加代码来监听从 Laravel 应用程序通过 Redis 数据库和 Laravel Echo 服务器发出的laravel_database_chat频道的MessageSent事件。在此文件中,我们编写以下代码:

...
<script>
import axios from "axios";
import { APIURL } from "../constants";
import NavBar from "@/components/NavBar";
export default {
  name: "Chatroom",
  components: {
    NavBar,
  },
  beforeMount() {
    this.getChatMessages();
    this.addChatListener();
  },
  data() {
    return {
      chatMessages: [],
      message: "",
    };
  },
  ...};
</script>

然后,我们在同一文件中添加方法来获取和发送聊天消息,编写以下代码:

...
<script>
...
export default {
  ...
  methods: {
    async getChatMessages() {
      const { id } = this.$route.params;
      const { data } = await 
        axios.get(`${APIURL}/api/message/${id}`);
      this.chatMessages = data;
      this.$nextTick(() => {
        const container = this.$refs.container;
        container.scrollTop = container.scrollHeight;
      });
    },
    async sendChatMessage() {
      const { message } = this;
      if (!message) {
        return;
      }
      const { id: chat_id } = this.$route.params;
        ...
        () => {
          this.getChatMessages();
        }
      );
    },
  },
</script>

getChatMessages方法从 API 获取聊天室的聊天消息,sendChatMessage方法通过向 API 发出 HTTP 请求提交聊天消息来向聊天室发送消息。然后,API 端点会通过 Laravel Echo 服务器将消息发送到队列,然后返回到此应用中使用的 Socket.IO 聊天客户端。我们调用addChatListener来监听服务器发送的laravel_database_chat事件,该事件调用getChatMessages以获取最新消息。

组件模板只是使用v-for指令来渲染chatMessages响应式属性的每个条目并将它们呈现出来。以下代码中的form元素用于让我们输入消息,然后通过发出 HTTP 请求将其提交到 Laravel。端点将消息保存到messages表,并且还会触发一个我们监听的事件,该事件通过 Redis 数据库和 Laravel Echo 服务器发送。前端只从实时通信的角度了解 Laravel Echo 服务器:

<template>
  <div>
    <NavBar></NavBar>
    <h1>Chatroom</h1>
    <div id="chat-messages" ref="container">
      <div class="row" v-for="m of chatMessages" 
        :key="m.id">
        <div>
          <b>{{ m.user.name }} - {{ m.created_at }}</b>
        </div>
        <div>{{ m.message }}</div>
      </div>
    </div>
    <form @submit.prevent="sendChatMessage">
      <div class="form-field">
        <label for="message">Message</label>
        <br />
        <input v-model="message" type="text" name="message"
           />
      </div>
      <div>
        <input type="submit" />
      </div>
    </form>
  </div>
</template>

component对象中,我们有beforeMount钩子来调用getChatMessage方法来获取聊天消息。addChatListener()方法使用 Socket.IO 客户端创建事件侦听器,让我们监听从 Laravel Echo 服务器发出的事件。在getChatMessage()方法中,我们调用this.$nextTick()方法并带有一个回调,以便我们在获取消息后始终滚动到包含消息的div标签的底部。我们在$nextTick回调中运行该代码,因为我们需要确保滚动代码在所有消息都呈现后运行。

this.$nextTick()方法让我们在响应式属性更新后等待组件重新渲染,然后再运行回调中的代码。

addChatListener()方法中,我们订阅了laravel_database_chat频道,这与我们在 Laravel 应用程序中定义的聊天频道相同。我们可以通过观察 Laravel Echo Server 的输出来确保我们订阅了正确的频道。.MessageSent事件与我们在后端应用程序中定义的事件相同。事件名称前的点是必需的,以便它在正确的命名空间中监听到正确的事件。在我们传递给监听器的回调函数中,我们调用this.getChatMessages()来获取最新的消息。

聊天消息的容器高度设置为 300px,这样当我们有太多消息时,它不会太高。它还让我们在有足够的消息溢出容器时滚动到底部:

<style scoped>
#chat-messages {
  height: 300px;
  overflow-y: scroll;
}
.row {
  display: flex;
  flex-wrap: wrap;
}
.row div:first-child {
  width: 30%;
}
</style>

接下来,在src/views文件夹中,我们通过编写以下代码创建Chatrooms.vue组件文件:

<template>
  <div>
    <NavBar></NavBar>
    <h1>Chatrooms</h1>
    <button @click="createChatRoom">Create Chatroom
      </button>
    <table id="table">
      <thead>
        <tr>
          <th>Name</th>
          <th>Go</th>
          <th>Edit</th>
          <th>Delete</th>
...
  beforeMount() {
    this.getChatRooms();
  },
};
</script>
<style scoped>
#table {
  width: 100%;
...
</style>

我们渲染了一个包含我们可以进入、编辑名称或删除的聊天室列表的表格。该方法只是获取聊天室数据,并转到编辑具有给定 ID 的聊天室的路由,添加聊天室的路由,重定向到具有给定 ID 的聊天室页面的路由,以及删除聊天室的路由。当我们删除聊天室时,我们再次使用getChatRooms()方法获取最新条目,以便获取最新数据。

我们在beforeMount钩子中获取聊天室列表,以便在页面加载时看到表格条目。接下来,在相同的文件夹中,我们创建EditChatroomForm.vue文件并添加以下代码:

<template>
  <div>
    <NavBar></NavBar>
    <ChatroomForm edit :id="$route.params.id">
      </ChatroomForm>
  </div>
</template>
<script>
import ChatroomForm from "@/components/ChatroomForm";
import NavBar from "@/components/NavBar";
export default {
  components: {
    ChatroomForm,
    NavBar,
  },
};
</script>

它与AddChatroomForm.vue文件具有相同的内容,但是ChatroomForm上的edit属性设置为trueid属性设置为从 Vue Router 获取的id URL 参数。

创建登录页面

接下来,我们通过创建src/views/Login.vue并添加以下代码来创建登录页面:

<template>
  <div>
    <h1>Login</h1>
    <form @submit.prevent="login">
      <div class="form-field">
        <label for="email">Email</label>
        <br />
        <input v-model="form.email" type="email" name="email" />
      </div>
      <div class="form-field">
        <label for="password">Password</label>
        <br />
        <input v-model="form.password" type="password"            name="password" />
      </div>
      <div>
        <input type="submit" value="Log in" />
        <button type="button" @click="goToRegister">Register</            button>
      </div>
    </form>
  </div>
</template>
<script>
import axios from "axios";
import { APIURL } from "../constants";
export default {
  name: "Login",
  data() {
    return {
      form: {
        email: "",
        password: "",
      },
    };
  },
  methods: {
    async login() {
      const { email, password } = this.form;
      if (!email || !password) {
        alert("Email and password are required");
        return;
      }
      try {
        const {
          data: { access_token },
        } = await axios.post(`${APIURL}/api/auth/login`, {
          email,
          password,
        });
        localStorage.setItem("token", access_token);
        this.$router.push("/");
      } catch (error) {
        alert("Invalid username or password");
      }
    },
    goToRegister() {
      this.$router.push("/register");
    },
  },
};
</script>

模板只有一个登录表单,用于输入电子邮件和密码以便我们登录。当我们提交表单时,将调用login()方法。它首先检查所有字段是否填写正确,然后使用凭据向/api/auth/login路由发出 HTTP 请求,以查看我们是否可以登录。

表单还有一个Register.vue文件,用于转到注册页面,以便我们可以注册账户并加入聊天室。

我们需要创建的最后一个页面是用于容纳注册表单的页面。为了创建它,我们编写以下代码:

<template>
  <div>
    <h1>Register</h1>
    <form @submit.prevent="register">
      <div class="form-field">
        <label for="email">Name</label>
        <br />
        <input v-model="form.name" type="text" name="name"
          />
      </div>
      <div class="form-field">
        <label for="email">Email</label>
        <br />
        <input v-model="form.email" type="email" 
          name="email" />
      </div>
      <div class="form-field">
        <label for="password">Password</label>
        <br />
          …
          name,
          email,
          password,
          password_confirmation: confirmPassword,
        });
        this.$router.push("/login");
      } catch (error) {
        alert("Invalid username or password");
      }
    },
  },
};
</script>

表格中有姓名电子邮件密码确认密码字段,所有这些字段都是注册账户所必需的。当我们提交表格时,我们调用register()方法。我们对字段进行检查,以查看它们是否填写正确。调用email正则表达式上的test()方法来检查有效的电子邮件地址。如果有效,则test()方法返回true。否则,它返回false。我们还检查密码是否与confirmPassword变量相同。如果一切正常,我们就会发出 POST 请求来注册用户账户。

src/App.vue中,我们用以下代码替换现有内容,以添加router-view组件,以便我们可以从src/views文件夹中看到路由组件:

<template>
  <div>
    <router-view />
  </div>
</template>
<style scoped>
div {
  width: 70vw;
  margin: 0 auto;
}
</style>
<style>
.form-field input {
  width: 100%;
}
</style>

然后,在src/router/index.js文件中,我们用以下代码替换现有内容,以注册所有路由,并使用 Laravel Echo 库创建Socket.io事件来监听:

...
window.io = require('socket.io-client');
const beforeEnter = (to, from, next) => {
  const hasToken = Boolean(localStorage.getItem('token'));
  if (!hasToken) {
    return next({ path: 'login' });
  }
  next();
}
const routes = [
  {
...
    path: '/edit-chatroom/:id',
    name: 'edit-chatroom/:id',
    component: EditChatroomForm,
    beforeEnter
  },
]
...
export default router

Laravel Echo 客户端与 Socket.IO 客户端一起使用,以便我们可以监听从 Laravel Echo 服务器广播的事件。broadcaster属性设置为'socket.io',以便我们可以监听来自 Laravel Echo 服务器的事件。host属性设置为 Laravel Echo 服务器的 URL。

此外,我们还有beforeEnter导航守卫,我们在之前的章节中已经看到了,当我们需要限制路由仅在身份验证成功后才可用时。我们只需检查令牌是否存在。如果存在,我们调用next来继续下一个路由。否则,我们重定向到登录页面。

现在我们可以通过运行npm run serve来运行前端,就像我们在所有其他项目中所做的那样。现在我们应该看到类似以下屏幕截图的东西。以下屏幕截图显示了聊天室用户界面:

图 8.2 – 聊天室的屏幕截图

图 8.2 – 聊天室的屏幕截图

以下屏幕截图是 Laravel Echo 服务器的工作情况。我们应该看到广播的事件名称以及发送的频道:

图 8.3 – 当聊天事件发送到前端时,Redis 的输出

图 8.3 – 当聊天事件发送到前端时,Redis 的输出

以下屏幕截图是队列事件的日志:

图 8.4 - Laravel 事件的输出

图 8.4 - Laravel 事件的输出

我们通过在backend文件夹中运行php artisan queue:listen来启动队列,该文件夹是 Laravel 项目所在的文件夹。

现在我们已经让聊天应用的前端工作了,我们使用 Laravel 和 Vue 创建了一个简单的聊天系统。

总结

在本章中,我们看了如何使用 Laravel 和 Vue 构建聊天应用。我们用 Laravel 构建了后端,并添加了控制器来接收请求。我们还使用了 Laravel 内置的队列系统将数据发送到前端。我们还在我们的 Laravel 应用中添加了 JSON Web Token 身份验证。

在前端,我们使用 Socket.IO 客户端来监听从 Laravel Echo 服务器发送的事件,该服务器通过 Redis 从 Laravel 获取数据。

现在我们已经通过 Vue 3 项目的各种难度,可以将我们在这里学到的东西应用到现实生活中。现实生活中的 Vue 应用几乎总是会向服务器发出 HTTP 请求。Axios 库使这变得容易。一些应用还会与服务器实时通信,就像本章中的聊天应用一样。

唯一的区别在于,在现实生活中的应用中,会有检查来查看用户是否经过身份验证并且被授权将数据发送到服务器。