领先的免费Web技术教程,涵盖HTML到ASP.NET

网站首页 > 知识剖析 正文

Rust Web编程:第八章 构建 RESTful 服务

nixiaole 2024-11-24 19:57:38 知识剖析 15 ℃

我们的待办事项应用程序是用 Rust 编写的,从技术上讲是可行的。 然而,我们还需要做出一些改进。 在本章中,我们将在探索 RESTful API 设计的概念时应用这些改进。

在本章中,我们最终将通过评估系统各层并重构我们在整个请求生命周期中处理请求的方式,在请求到达视图之前拒绝未经授权的用户。 然后,我们将使用此身份验证使个人用户能够拥有自己的待办事项列表。 最后,我们将记录我们的请求,以便我们可以对应用程序进行故障排除并更深入地了解应用程序的运行方式,在前端缓存数据以减少 API 调用。 我们还将探索一些不错的概念,例如根据命令执行代码以及创建统一的界面以将前端 URL 与后端 URL 分开。

在本章中,我们将讨论以下主题:

什么是 RESTful 服务?

映射我们的分层系统

建立统一的界面

实现无国籍状态

记录我们的服务器流量

缓存

按需编码

到本章结束时,我们将重构 Rust 应用程序以支持 RESTful API 的原则。 这意味着我们将映射 Rust 应用程序的各层,创建统一的 API 端点,在应用程序中记录请求,并在前端缓存结果。

什么是 RESTful 服务?

REST 代表代表性状态转移。 它是我们的应用程序编程接口 (API) 读取 (GET)、更新 (PUT)、创建 (POST) 和删除 (DELETE) 用户和待办事项的架构风格。 RESTful 方法的目标是通过重用可在不影响系统的情况下管理和更新的组件来提高速度/性能、可靠性和增长能力。

您可能已经注意到,在 Rust 之前,缓慢的高级语言似乎是 Web 开发的明智选择。 这是因为它们写起来更快更安全。 这是由于网络开发中处理数据速度的主要瓶颈是网络连接速度。 RESTful设计旨在通过节省系统来提高速度,例如减少API调用,而不是仅仅关注算法速度。 考虑到这一点,在本节中,我们将介绍以下 RESTful 概念:

分层系统:这使我们能够添加额外的功能,例如授权,而无需更改界面。 例如,如果我们必须在每个视图中检查 JSON Web Token (JWT),那么这将是大量重复代码,难以维护且容易出错。

统一系统:这简化了架构并解耦了架构,使应用程序的整个部分能够独立发展而不会发生冲突。

无状态性:这确保我们的应用程序不会直接在服务器上保存任何内容。 这对微服务和云计算有影响。

日志记录:这使我们能够窥视我们的应用程序并查看它是如何运行的,即使没有显示错误,也可以暴露不良行为。

缓存:这使我们能够在前端存储数据,以减少对后端 API 的 API 调用数量。

按需代码:这是我们的后端服务器直接在前端运行代码的地方。

我们将在下一节中讨论分层系统概念。

映射我们的分层系统

分层系统由具有不同功能单元的层组成。 可以说这些层是不同的服务器。 这在微服务和大型系统中都是如此。 当涉及到不同的数据层时,可能会出现这种情况。 在大型系统中,拥有定期访问和更新的热数据和很少访问的冷数据是有意义的。 然而,虽然很容易将层视为位于不同的服务器上,但它们也可以位于同一服务器上。 我们可以用下图来映射我们的图层:


如您所见,我们的应用程序遵循以下流程:

首先,我们的 HTTP 处理程序通过侦听我们在创建服务器时定义的端口来接受调用。

然后,它通过中间件,该中间件是通过在我们的应用程序上使用wrap_fn函数定义的。

完成此操作后,请求的 URL 将映射到正确的视图以及我们在 src/json_serialization/ 目录中定义的模式。 这些被传递到 src/views 目录中定义的资源(我们的视图)中。

如果我们随后想要更新或从数据库获取数据,我们可以使用 Diesel ORM 来映射这些请求。 在这个阶段,除了中间件之外,我们的所有层都已定义为有效管理数据流。 正如前一章(第 7 章“管理用户会话”)中所指出的,我们通过实现 FromRequest 特征,使用 JwToken 结构实现了用于身份验证的中间件。 有了这个,我们可以看到我们可以使用wrap_fn或实现FromRequest特征来实现我们的中间件。 您认为我们什么时候应该使用wrap_fn 或FromRequest 特征? 两者都有优点和缺点。

如果我们想为特定的单个视图实现中间件,那么实现 FromRequest 特征是最好的选择。 这是因为我们可以将实现 FromRequest 特征的结构插入到我们想要的视图中。 身份验证是实现 FromRequest 特征的一个很好的用例,因为我们想要选择哪些端点需要身份验证。 然而,如果我们想实现一揽子规则,我们最好在wrap_fn函数中实现身份验证视图的选择。 在wrap_fn函数中实现我们的中间件意味着它是针对每个请求实现的。

一个例子是我们不再支持所有端点的版本一。 如果我们要这样做,我们就必须警告第三方用户我们决定不再支持 API 版本一。 一旦我们的日期过去,我们将不得不提供一个有用的信息,即我们不再支持版本一。 在开始处理中间件层之前,我们必须在 main.rs 文件的顶部定义以下导入:

use actix_web::{App, HttpServer, HttpResponse};
use actix_service::Service;
use futures::future::{ok, Either};
use actix_cors::Cors;

为了确保我们知道传入请求的目的地是 v1 端点,我们必须定义一个标志,稍后在决定是处理请求还是拒绝请求时可以检查该标志。 我们可以通过在 main.rs 文件中使用以下代码来做到这一点:

.wrap_fn(|req, srv|{
    let passed: bool;
    if req.path().contains("/v1/") {
        passed = false;
    } else {
        passed = true;
    }
. . .

从前面的代码中我们可以看到,我们声明了passed这个名字下有一个Boolean。 如果 v1 不在 URL 中,则将其设置为 true。 如果 URL 中存在 v1,则 passed 设置为 false。

现在我们已经定义了一个标志,我们可以使用它来指示请求会发生什么。 在执行此操作之前,我们必须记下 wrap_fn 的最后几行,如以下代码块所示:

let future = srv.call(req);
async {
    let result = fut.await?;
    Ok(result)
}

我们正在等待调用完成,然后将结果作为名为 result 的变量返回。 在调用 v1 API 的阻塞后,我们必须检查请求是否通过。 如果是,我们就运行前面的代码。 但是,如果请求失败,我们必须绕过这个并定义另一个 future,这只是响应。

从表面上看,这似乎很简单。 两者都会返回相同的东西,即响应。 但是,Rust 无法编译。 它将根据不兼容的类型抛出错误。 这是因为异步块的行为类似于闭包。 这意味着每个异步块都是其自己的类型。 这可能会令人沮丧,并且由于这个微妙的细节,它可能会导致开发人员花费大量时间试图让两个 future 相互配合。

幸运的是,futures 箱中有一个枚举可以为我们解决这个问题。 Either 枚举将具有相同关联类型的两个不同的 future、流或接收器组合成一个类型。 这使我们能够匹配传递的标志,并使用以下代码触发并返回适当的进程:

    let end_result;
    if passed == true {
        end_result = Either::Left(srv.call(req))
    }
    else {
        let resp = HttpResponse::NotImplemented().body(
            "v1 API is no longer supported"
            );
        end_result = Either::Right(
            ok(req.into_response(resp)
                  .map_into_boxed_body())
        )
    }
    async move {
        let result = end_result.await?;
        Ok(result)
    }
}).configure(views::views_factory).wrap(cors);

从前面的代码中,我们可以看到我们将 end_result 指定为视图来调用,或者根据传递的标志直接将其返回给未经授权的响应。 然后我们在wrap_fn 的末尾返回它。 了解如何使用 Either 枚举是一个方便的技巧,当您需要代码在两个不同的 future 之间进行选择时,可以节省您的时间。

要检查我们是否阻止了 v1,我们可以调用一个简单的 get 请求,如下图所示:



我们可以看到我们的 API 调用被阻止并显示一条有用的消息。 如果我们通过 Postman 进行 API 调用,我们将看到收到 501 Not Implemented 错误,如下图所示:



我们可能希望添加更多资源来获取将来的物品。 这会带来潜在的问题,因为某些视图可能会与应用程序视图发生冲突。 例如,我们的待办事项 API 视图只有前缀 item。 获取所有项目需要 v1/item/get 端点。

为应用程序开发一个视图来详细查看待办事项以便稍后使用 v1/item/get/{id} 端点进行编辑可能是合理的。 然而,这增加了前端应用程序视图和后端 API 调用之间发生冲突的风险。 为了防止这种情况发生,我们必须确保我们的 API 具有统一的接口。

建立统一的界面

拥有统一的接口意味着我们的资源可以通过 URL 进行唯一标识。 这将后端端点和前端视图解耦,使我们的应用程序能够在前端视图和后端端点之间不发生冲突的情况下进行扩展。 我们使用版本标签将后端与前端解耦。 当 URL 端点包含版本标签(例如 v1 或 v2)时,我们知道调用正在访问后端 Rust 服务器。 当我们开发 Rust 服务器时,我们可能想要使用更新版本的 API 调用。 但是,我们不希望允许用户访问正在开发的版本。 为了使实时用户能够访问一个版本,同时在测试服务器上部署另一个版本,我们需要动态定义服务器的 API 版本。

根据到目前为止您在本书中获得的知识,您可以简单地在 config.yml 文件中定义版本号并加载它。 但是,我们必须为每个请求读取 config.yml 配置文件。 请记住,当我们设置数据库连接池时,我们从 config.yml 文件中读取一次连接字符串,这意味着它在程序的整个生命周期中都存在。 我们希望定义一次版本,然后在程序的生命周期中引用它。 直观上,您可能希望在定义服务器之前在 main.rs 文件的 main 函数中定义版本,然后访问 wrap_fn 内的版本定义,如下例所示:

let outcome = "test".to_owned();
HttpServer::new(|| {
    . . .
    let app = App::new()
        .wrap_fn(|req, srv|{
            println!("{}", outcome);
            . . .
        }
    . . .

但是,如果我们尝试编译前面的代码,它将失败,因为结果变量的生命周期不够长。 我们可以使用以下代码将结果变量转换为常量:

const OUTCOME: &str = "test";
HttpServer::new(|| {
    . . .
    let app = App::new()
        .wrap_fn(|req, srv|{
            println!("{}", outcome);
            . . .
        }
    . . .

前面的代码将运行而不会出现任何生命周期问题。 但是,如果我们要加载我们的版本,我们将必须从文件中读取它。 在 Rust 中,如果我们从文件中读取,我们不知道从文件中读取的变量的大小是多少。 因此,我们从文件中读取的变量将是一个字符串。 这里的问题是分配字符串不是可以在编译时计算的。 因此,我们必须将版本直接写入 main.rs 文件中。 我们可以通过使用构建文件来做到这一点。

笔记

我们在此问题中利用构建文件来教授构建文件的概念,以便您可以在需要时使用它们。 没有什么可以阻止您在代码中对常量进行硬编码。

这是运行 Rust 应用程序之前运行单个 Rust 文件的位置。 当我们编译主 Rust 应用程序时,该构建文件将自动运行。 我们可以使用以下代码在 Cargo.toml 文件的构建依赖项部分中定义运行构建文件所需的依赖项:

[package]
name = "web_app"
version = "0.1.0"
edition = "2021"
build = "build.rs"
[build-dependencies]
serde_yaml = "0.8.23"
serde = { version = "1.0.136", features = ["derive"] }

这意味着我们的构建 Rust 文件是在应用程序根目录的 build.rs 文件中定义的。 然后,我们将在 [build-dependencies] 部分中定义构建阶段所需的依赖项。 现在我们的依赖关系已经定义,我们的 build.rs 文件可以采用以下形式:

use std::fs::File;
use std::io::Write;
use std::collections::HashMap;
use serde_yaml;
fn main() {
  let file =
      std::fs::File::open("./build_config.yml").unwrap();
  let map: HashMap<String, serde_yaml::Value> =
      serde_yaml::from_reader(file).unwrap();
  let version =
      map.get("ALLOWED_VERSION").unwrap().as_str()
          .unwrap();
  let mut f =
      File::create("./src/output_data.txt").unwrap();
  write!(f, "{}", version).unwrap();
}

在这里我们可以看到,我们需要导入我们需要从 YAML 文件中读取的内容并将其写入标准文本文件。 然后,我们将打开一个 build_config.yml 文件,该文件位于 Web 应用程序的根目录中 config.yml 文件旁边。 然后,我们将从 build_config.yml 文件中提取 ALLOWED_VERSION 并将其写入文本文件。 现在我们已经定义了构建过程以及 build_config.yml 文件所需的内容,我们的 build_config.yml 文件必须采用以下形式:

ALLOWED_VERSION: v1

现在我们已经为构建定义了所有内容,我们可以通过在 build.rs 文件中写入的文件为我们的版本引入一个 const 实例。 为此,我们的 main.rs 文件需要进行一些更改。 首先,我们使用以下代码定义const:

const ALLOWED_VERSION: &'static str = include_str!(
    "./output_data.txt");
HttpServer::new(|| {
. . .

如果版本允许,我们认为请求通过,代码如下:

HttpServer::new(|| {
. . .
let app = App::new()
.wrap_fn(|req, srv|{
    let passed: bool;
    if *&req.path().contains(&format!("/{}/",
                             ALLOWED_VERSION)) {
        passed = true;
    } else {
        passed = false;
    }

然后,我们使用以下代码定义错误响应和服务调用:

. . .
let end_result = match passed {
    true => {
        Either::Left(srv.call(req))
    },
    false => {
        let resp = HttpResponse::NotImplemented()
            .body(format!("only {} API is supported",
                ALLOWED_VERSION));
        Either::Right(
            ok(req.into_response(resp).map_into_boxed_body())
        )
    }
};
. . .

我们现在准备构建并运行支持特定版本的应用程序。 如果我们运行应用程序并发出 v2 请求,我们会收到以下响应:



我们可以看到我们的版本防护现在正在工作。 这也意味着我们必须使用 React 应用程序来访问前端视图,或者您可以将 v1 添加到前端 API 端点。

现在,如果我们运行我们的应用程序,我们可以看到我们的前端可以与新端点配合使用。 至此,我们距离为应用程序开发 RESTful API 又近了一步。 但是,我们仍然存在一些明显的缺点。 现在,我们可以创建另一个用户并在该用户下登录。 在下一节中,我们将探讨如何以无状态方式管理用户状态。

实现无状态

无状态是指服务器不存储有关客户端会话的任何信息。 这里的优点很简单。 它使我们的应用程序能够更轻松地扩展,因为我们通过在客户端存储会话信息来释放服务器端的资源。

它还使我们能够更加灵活地使用我们的计算方法。 例如,假设我们的应用程序突然流行起来。 因此,我们可能希望在两个计算实例或服务器上运行我们的应用程序,并让负载均衡器以平衡的方式将流量引导到两个实例。 如果信息存储在服务器上,用户将获得不一致的体验。

他们可能会更新一个计算实例上的会话状态,但随后,当他们发出另一个请求时,他们可能会访问另一个具有过时数据的计算实例。 考虑到这一点,无状态性不能仅仅通过将所有内容存储在客户端中来实现。 如果我们的数据库不依赖于我们应用程序的计算实例,我们也可以将数据存储在这个数据库上,如下图所示:



如您所见,我们的应用程序已经是无状态的。 它将用户 ID 存储在前端的 JWT 中,我们将用户数据模型和待办事项存储在 PostgreSQL 数据库中。 然而,我们可能想在我们的应用程序中存储 Rust 结构。 例如,我们可以构建一个结构体来计算到达服务器的请求数量。 参考图 8.5,我们不能只将结构保存在服务器本地。 相反,我们将结构存储在 Redis 中,执行下图所示的过程:



PostgreSQL 和 Redis 之间的区别

Redis 是一个数据库,但它与 PostgreSQL 数据库不同。 Redis 更接近于键值存储。 由于数据位于内存中,Redis 也很快。 虽然 Redis 在管理表以及表之间的相互关系方面不如 PostgreSQL 完善,但 Redis 确实有优势。 Redis 支持有用的数据结构,例如列表、集合、哈希、队列和通道。 您还可以为插入 Redis 的数据设置过期时间。 您也不需要使用 Redis 处理数据迁移。 这使得 Redis 成为缓存您需要快速访问的数据的理想数据库,但您不太关心持久性。 对于通道和队列,Redis 也是促进订阅者和发布者之间通信的理想选择。

我们可以通过执行以下步骤来实现图8.6中的过程:

为 Docker 定义 Redis 服务。

更新 Rust 依赖项。

更新 Redis 连接的配置文件。

构建一个可以在 Redis 数据库中保存和加载的计数器结构。

为每个请求实现计数器。

让我们详细回顾一下每个步骤:

在启动 Redis Docker 服务时,我们需要使用具有标准端口的标准 Redis 容器。 实现 Redis 服务后,我们的 docker-compose.yml 文件应具有当前状态:

version: "3.7"

services:

  postgres:

    container_name: 'to-do-postgres'

    image: 'postgres:11.2'

    restart: always

    ports:

      - '5433:5432'

    environment:

      - 'POSTGRES_USER=username'

      - 'POSTGRES_DB=to_do'

      - 'POSTGRES_PASSWORD=password'

  redis:

      container_name: 'to-do-redis'

      image: 'redis:5.0.5'

      ports:

        - '6379:6379'

我们可以看到,现在本地计算机上运行着 Redis 服务和数据库服务。 现在 Redis 可以运行了,我们需要在下一步中更新我们的依赖项。

回想图 8.6,我们需要将 Rust 结构序列化为字节,然后再将其插入 Redis。 考虑到这些步骤,我们的 Cargo.toml 文件中需要以下依赖项:

[dependencies]

. . .

redis = "0.21.5"

我们正在使用 redis crate 连接到 Redis 数据库。 现在我们的依赖关系已经定义了,我们可以开始在下一步中定义我们的配置文件。

当涉及到我们的 config.yml 文件时,我们必须添加 Redis 数据库连接的 URL。 在本书的此时,我们的 config.yml 文件应具有以下形式:

DB_URL: postgres://username:password@localhost:5433/to_do

SECRET_KEY: secret

EXPIRE_MINUTES: 120

REDIS_URL: redis://127.0.0.1/

我们尚未为 REDIS_URL 参数添加端口号。 这是因为我们在 Redis 服务中使用标准端口,即 6379,因此我们不必定义该端口。 现在,我们已准备好定义可以连接到 Redis 的结构的所有数据,我们将在下一步中执行此操作。

我们将在 src/counter.rs 文件中定义 Counter 结构。 首先,我们必须导入以下内容:

use serde::{Deserialize, Serialize};

use crate::config::Config;

我们将使用 Config 实例来获取 Redis URL,并使用反序列化和序列化特征来启用字节转换。 我们的 Counter 结构采用以下形式:

#[derive(Serialize, Deserialize, Debug)]

pub struct Counter {

    pub count: i32

}

现在我们已经定义了包含所有特征的 Counter 结构,我们需要使用以下代码定义操作所需的函数:


impl Counter {

    fn get_redis_url() -> String {

        . . .

    }

    pub fn save(self) {

        . . .

    }

    pub fn load() -> Counter {

        . . .

    }

}

定义了前面的函数后,我们可以将 Counter 结构加载并保存到 Redis 数据库中。 在构建 get_redis_url 函数时,它可以采用以下形式也就不足为奇了:

fn get_redis_url() -> String {

    let config = Config::new();

    config.map.get("REDIS_URL")

              .unwrap().as_str()

              .unwrap().to_owned()

}

现在我们有了 Redis URL,我们可以使用以下代码保存 Counter 结构:

pub fn save(self) -> Result<(), redis::RedisError> {

    let serialized = serde_yaml::to_vec(&self).unwrap();

    let client = match redis::Client::open(

                     Counter::get_redis_url()) {

        Ok(client) => client,

        Err(error) => return Err(error)

    };

    let mut con = match client.get_connection() {

        Ok(con) => con,

        Err(error) => return Err(error)

    };

    match redis::cmd("SET").arg("COUNTER")

                           .arg(serialized)

                           .query::<Vec<u8>>(&mut con) {

        Ok(_) => Ok(()),

        Err(error) => Err(error)

    }

}

在这里,我们可以看到我们可以将 Counter 结构序列化为 Vec<u8>。 然后,我们将为 Redis 定义客户端,并在键“COUNTER”下插入序列化的 Counter 结构。 Redis 还有更多功能,但是,您可以在本章中使用 Redis,将 Redis 视为一个大型可扩展的内存中哈希图。 考虑到 hashmap 的概念,您认为我们如何从 Redis 数据库获取 Counter 结构体? 您可能已经猜到了; 我们使用带有“COUNTER”键的 GET 命令,然后使用以下代码对其进行反序列化:

pub fn load() -> Result<Counter, redis::RedisError> {

    let client = match redis::Client::open(

                     Counter::get_redis_url()){

        Ok(client) => client,

        Err(error) => return Err(error)

    };

    let mut con = match client.get_connection() {

        Ok(con) => con,

        Err(error) => return Err(error)

    };

    let byte_data: Vec<u8> = match redis::cmd("GET")

                                 .arg("COUNTER")

                                 .query(&mut con) {

        Ok(data) => data,

        Err(error) => return Err(error)

    };

    Ok(serde_yaml::from_slice(&byte_data).unwrap())

}

我们现在已经定义了 Counter 结构。 我们已经在下一步的 main.rs 文件中实现了所有要实现的内容。

当每次收到请求时将计数加一,我们需要在 main.rs 文件中执行以下代码:


. . .

mod counter;

. . .

#[actix_web::main]

async fn main() -> std::io::Result<()> {

    . . .

    let site_counter = counter::Counter{count: 0};

    site_counter.save();

    HttpServer::new(|| {

        . . .

        let app = App::new()

            .wrap_fn(|req, srv|{

                let passed: bool;

                let mut site_counter = counter::

                                       Counter::load()

                                       .unwrap();

                site_counter.count += 1;

                println!("{:?}", &site_counter);

                site_counter.save();

                . . .

在这里,我们可以看到我们定义了计数器模块。 在启动服务器之前,我们需要创建新的 Counter 结构并将其插入到 Redis 中。 然后我们从 Redis 获取计数器,增加计数,然后为每个请求保存它。

现在,当我们运行服务器时,我们可以看到每次我们向服务器发出请求时,计数器都会增加。 我们的打印输出应如下所示:

Counter { count: 1 }
Counter { count: 2 }
Counter { count: 3 }
Counter { count: 4 }

现在我们已经集成了另一个存储选项,我们的应用程序基本上按照我们想要的方式运行。 如果我们现在想发布我们的应用程序,没有什么可以真正阻止我们使用 Docker 配置构建并将其部署到具有数据库和 NGINX 的服务器上。

并发问题

如果两个服务器同时请求计数器,则存在丢失请求的风险。 我们探讨了计数器示例来演示如何在 Redis 中存储序列化结构。 如果您需要在Redis数据库中实现一个简单的计数器,并且对并发性比较关心,建议您使用INCR命令。 INCR 命令将您在 Redis 数据库中选择的键下的数字加一,并返回新增加的数字作为结果。 由于 Redis 数据库中计数器的增加,我们降低了并发问题的风险。

然而,我们总是可以添加一些东西。 在下一节中,我们将研究日志记录请求。

记录我们的服务器流量

到目前为止,我们的应用程序没有记录任何内容。 这不会直接影响应用程序的运行。 然而,日志记录也有一些优点。 日志记录使我们能够调试我们的应用程序。

目前,由于我们正在本地开发,似乎并不真正需要日志记录。 然而,在生产环境中,应用程序失败的原因有很多,其中包括 Docker 容器编排问题。 记录发生了什么进程的日志可以帮助我们查明错误。 我们还可以使用日志记录来查看何时出现边缘情况和错误,以便监控应用程序的总体运行状况。 在日志记录方面,我们可以构建四种类型的日志:

信息(info):这是一般日志记录。 如果我们想要跟踪一般流程及其进展情况,我们可以使用这种类型的日志。 使用此功能的示例包括启动和停止服务器以及记录我们想要监视的某些检查点,例如 HTTP 请求。

详细:这是诸如上一点中定义的类型之类的信息。 然而,它可以更细致地告诉我们更详细的流程流程。 此类日志主要用于调试目的,在生产设置中通常应避免使用。

警告:当我们记录失败且不应被忽略的进程时,我们使用此类型。 但是,我们可以使用它而不是引发错误,因为我们不希望服务被中断或用户知道特定的错误。 日志本身可以提醒我们问题,以便我们采取行动。 诸如对另一台服务器的调用失败之类的问题适合此类别的问题。

错误:这是流程因错误而中断的地方,我们需要尽快解决它。 我们还需要通知用户交易未完成。 一个很好的例子就是连接数据库或将数据插入数据库失败。 如果发生这种情况,则没有交易发生的记录,无法追溯解决。 但需要注意的是,该进程可以继续运行。

如果出现有关服务器无法发送电子邮件的警告,请连接到另一台服务器以分派产品进行运输,等等。 一旦我们解决了问题,我们就可以追溯地对该时间范围内的事务进行数据库调用,并使用正确的信息对服务器进行调用。

最坏的情况下,还会有延迟。 对于错误类型,我们将无法进行数据库调用,因为在订单输入数据库之前服务器就被错误中断了。 考虑到这一点,很明显为什么错误日志记录非常重要,因为需要通知用户存在问题并且他们的事务没有完成,提示他们稍后重试。

我们可以考虑在错误日志中包含足够的信息,以便追溯并更新数据库,并在问题解决后完成其余过程,从而无需通知用户。 虽然这很诱人,但我们必须考虑两件事。 日志数据通常是非结构化的。

对于日志中的内容没有质量控制。 因此,一旦我们最终设法将日志数据处理为正确的格式,损坏的数据仍然有可能进入数据库。

第二个问题是日志不被认为是安全的。 它们在危机中被复制并发送给其他开发人员,并且可以插入其他管道和网站(例如 Bugsnag)以监视日志。 考虑到日志的性质,在日志中包含任何可识别信息并不是一个好的做法。

现在我们已经了解了日志记录的用途,我们可以开始配置我们自己的记录器了。 当谈到日志记录时,我们将使用 Actix-web 日志记录器。 这为我们提供了记录内容的灵活性,同时配置了底层日志记录机制并与我们的 Actix 服务器良好配合。 要构建我们的记录器,我们必须使用以下代码在 Cargo.toml 文件中定义一个新的crate:

[dependencies]
. . .
env_logger = "0.9.0"

这使我们能够使用环境变量配置记录器。 我们现在可以关注 main.rs,因为这是配置和使用我们的记录器的地方。 首先,我们将使用以下代码导入记录器:

use actix_web::{. . ., middleware::Logger};

通过此导入,我们可以使用以下代码在主函数中定义记录器:

. . .
#[actix_web::main]
async fn main() -> std::io::Result<()> {
    . . .
    env_logger::init_from_env(env_logger::Env::new()
                              .default_filter_or("info"));
. . .

在这里,我们声明我们的记录器将把信息记录到信息流中。 配置记录器后,我们可以使用以下代码用记录器包装我们的服务器:

. . .
        async move {
            let result = end_result.await?;
            Ok(result)
        }
}).configure(views::views_factory).wrap(cors)
    .wrap(Logger::new("%a %{User-Agent}i %r %s %D"));
return app
. . .

我们可以在记录器中看到我们传入了“&a %{User-Agent}I %r %s %D”字符串。 该字符串由记录器解释,告诉他们要记录什么。 Actix 记录器可以接受以下输入:

%%:百分号

%a:远程IP地址(如果使用反向代理则为代理的IP地址)

%t:请求开始处理的时间

%P:为请求提供服务的子进程 ID

%r:请求的第一行

%s:响应状态码

%b:响应的大小(以字节为单位),包括 HTTP 标头

%T:服务请求所花费的时间,以秒为单位,采用 .06f 格式的浮动分数

%D:服务请求所花费的时间,以毫秒为单位

%{FOO}i: request.headers['FOO']

%{FOO}o:response.headers['FOO']

%{FOO}e: os.environ['FOO']

通过这些输入,我们可以计算出我们将记录远程 IP 地址、用户代理、请求端点以及处理请求所需的时间。 我们将为 Rust 服务器的每个请求执行此操作。 使用日志记录启动 Rust 服务器会得到以下输出:

[2022-05-25T17:22:32Z INFO  actix_server::builder] Starting 8 workers
[2022-05-25T17:22:32Z INFO  actix_server::server] Actix runtime found; starting in Actix runtime

在这里,我们可以看到自动记录服务器启动时间和工作人员数量。 然后,如果我们启动前端,系统应该提示我们登录,因为令牌现在应该已经过期。 完整的标准请求日志应类似于以下输出:

[2022-05-25T17:14:56Z INFO  actix_web::middleware::logger]
127.0.0.1 Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/101.0.4951.64 Safari/537.36
GET /v1/item/get HTTP/1.1 401 9.466000

我们可以看到时间、它是 INFO 级别日志的事实以及哪个记录器正在记录它。 我们还可以看到我的 IP 地址(当我在本地运行应用程序时该地址是本地的)、我的计算机/浏览器详细信息以及带有 401 响应代码的 API 调用。 如果我们删除请求日志中除方法、API 端点、响应代码和响应时间之外的所有内容,我们的登录提示将如下所示:

GET /v1/item/get HTTP/1.1 401 9.466000
OPTIONS /v1/auth/login HTTP/1.1 200 0.254000
POST /v1/auth/login HTTP/1.1 200 1405.585000
OPTIONS /v1/item/get HTTP/1.1 200 0.082000
GET /v1/item/get HTTP/1.1 200 15.470000

我们可以看到我无法获取物品,并且响应未经授权。 然后我登录并从登录中获得 OK 响应。 在这里,我们可以看到一个 OPTIONS 和 POST 方法。 OPTIONS 方法适用于我们的 CORS,这就是为什么 OPTIONS 调用处理时间只是其他 API 请求的一小部分的原因。 我们可以看到,我们获得了随后呈现到页面的项目。 但是,当我们刷新页面时,我们可以看到以下日志中发生了什么:

OPTIONS /v1/item/get HTTP/1.1 200 0.251000
OPTIONS /v1/item/get HTTP/1.1 200 0.367000
GET /v1/item/get HTTP/1.1 200 88.317000
GET /v1/item/get HTTP/1.1 200 9.523000

我们可以看到有两个针对该项目的 GET 请求。 但是,我们没有更改数据库中的待办事项。 这不是错误,但这是浪费。 为了优化这一点,我们可以在下一节中利用缓存的 REST 约束。

缓存

缓存是我们在前端存储数据以供重用的地方。 这使我们能够减少对后端的 API 调用次数并减少延迟。 因为好处如此明显,所以很容易缓存所有内容。 然而,有一些事情需要考虑。

并发是一个明确的问题。 数据可能会过时,从而在向后端发送错误信息时导致混乱和数据损坏。 还有安全问题。 如果一个用户注销而另一个用户在同一台计算机上登录,则存在第二个用户能够访问第一个用户的项目的风险。 有了这个,必须进行一些检查。 需要登录正确的用户,并且需要为数据添加时间戳,以便在超过特定时间段访问缓存数据时,会发出 GET 请求来刷新数据。

我们的应用程序是相当锁定的。 除非登录,否则我们无法访问任何内容。我们可以在应用程序中缓存的主要过程是 GET 项目调用。 在后端编辑项目列表状态的所有其他调用都会返回更新的项目。 考虑到这一点,我们的缓存机制如下所示:


Figure 8.7 – Our caching approach

刷新页面时,图中的循环可以执行任意多次。 然而,这可能不是一个好主意。 如果用户在厨房时在手机上登录我们的应用程序来更新列表,那么当用户返回计算机做一些工作、刷新计算机上的页面并更新列表时,就会遇到问题 。 该缓存系统将使用户暴露于将发送到后端的过时数据。 我们可以通过引用时间戳来降低发生这种情况的风险。 当时间戳早于截止时间时,我们将在用户刷新页面时进行另一个 API 调用来刷新数据。

当涉及到我们的缓存逻辑时,它将全部实现在 front_end/src/App.js 文件中的 getItems 函数下。 我们的 getItems 函数将采用以下布局:

getItems() {
  . . .
  if (difference <= 120) {
      . . .
  }
  else {
      axios.get("http://127.0.0.1:8000/v1/item/get",
          {headers: {"token": localStorage
                      .getItem("token")}}).then(response => {
              . . .
              })
          }).catch(error => {
          if (error.response.status === 401) {
              this.logout();
          }
      });
  }
}

这里,我们规定最后缓存的项目与当前时间之间的时间差必须小于120秒,即2分钟。 如果时间差低于 2 分钟,我们将从缓存中获取数据。 但是,如果时间差超过 2 分钟,我们就会向 API 后端发出请求。 如果我们收到未经授权的回复,我们将注销。 首先,在此 getItems 函数中,我们使用以下代码获取项目的缓存日期并计算当时和现在之间的差异:

let cachedData = Date.parse(localStorage
                            .getItem("item-cache-date"));
let now = new Date();
let difference = Math.round((now - cachedData) / (1000));

如果我们的时间差是 2 分钟,我们将从本地存储获取数据,并使用以下代码使用该数据更新我们的状态:

let pendingItems =
    JSON.parse(localStorage.getItem("item-cache-data-pending"));
let doneItems =
    JSON.parse(localStorage.getItem("item-cache-data-
                                     done"));
let pendingItemsCount = pendingItems.length;
let doneItemsCount = doneItems.length;
this.setState({
  "pending_items": this.processItemValues(pendingItems),
  "done_items": this.processItemValues(doneItems),
  "pending_items_count": pendingItemsCount,
  "done_items_count": doneItemsCount
})

这里,我们必须从本地存储中解析数据,因为本地存储只处理字符串数据。 由于本地存储仅处理字符串,因此当我们使用以下代码发出 API 请求时,必须对要插入本地存储的数据进行字符串化:

let pending_items = response.data["pending_items"]
let done_items = response.data["done_items"]
localStorage.setItem("item-cache-date", new Date());
localStorage.setItem("item-cache-data-pending",
                      JSON.stringify(pending_items));
localStorage.setItem("item-cache-data-done",
                      JSON.stringify(done_items));
this.setState({
  "pending_items": this.processItemValues(pending_items),
  "done_items": this.processItemValues(done_items),
  "pending_items_count":
      response.data["pending_item_count"],
  "done_items_count": response.data["done_item_count"]
})

如果我们运行我们的应用程序,我们只进行一次 API 调用。 如果我们在 2 分钟结束之前刷新应用程序,我们可以看到,尽管我们的前端渲染了缓存中的所有项目,但没有新的 API 调用。 但是,如果我们创建、编辑或删除某个项目,然后在 2 分钟结束之前刷新页面,我们将看到视图将恢复到之前的过时状态。 这是因为创建、编辑和删除的项目也会返回到之前的状态,但不会存储在本地存储中。 这可以通过使用以下代码更新我们的handleReturnedState函数来处理:

handleReturnedState = (response) => {
  let pending_items = response.data["pending_items"]
  let done_items = response.data["done_items"]
  localStorage.setItem("item-cache-date", new Date());
  localStorage.setItem("item-cache-data-pending",
                        JSON.stringify(pending_items));
  localStorage.setItem("item-cache-data-done",
                        JSON.stringify(done_items));
  this.setState({
     "pending_items":this.processItemValues(pending_items),
     "done_items": this.processItemValues(done_items),
     "pending_items_count":response
         .data["pending_item_count"],
      "done_items_count": response.data["done_item_count"]
  })
}

在这里,我们有它。 我们设法缓存数据并重用它,以防止我们的后端 API 受到过度攻击。 这也可以应用于其他前端流程。 例如,可以缓存客户购物篮并在用户结账时使用。

这使我们的简单网站更接近成为网络应用程序。 然而,我们必须承认,随着我们更多地使用缓存,前端的复杂性也会增加。 对于我们的应用程序来说,这是缓存停止的地方。 目前,在剩下的时间里,我们的应用程序不需要再进行任何更改。 然而,我们还应该简要介绍一下另一个概念,那就是按需编写代码。

按需编码

按需代码是后端服务器直接执行前端代码的地方。 该约束是可选的并且并未广泛使用。 但是,它很有用,因为它使后端服务器有权决定何时在前端执行代码。 我们已经这样做了; 在我们的注销视图中,我们通过简单地以字符串返回它来直接在前端执行 JavaScript。 这是在 src/views/auth/logout.rs 文件中完成的。 我们必须记住,我们现在已将待办事项添加到本地存储中。 如果我们在注销时不从本地存储中删除这些项目,则其他人在 2 分钟内设法在同一台计算机上登录自己的帐户时,将能够访问我们的待办事项。 虽然这种情况极不可能发生,但我们还是安全的好。 请记住,src/views/auth/logout.rs 文件中的注销视图采用以下形式:

pub async fn logout() -> HttpResponse {
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(. . .)
}

在我们的回复正文中,我们有以下内容:

"<html>\
<script>\
    localStorage.removeItem('user-token'); \
    localStorage.removeItem('item-cache-date'); \
    localStorage.removeItem('item-cache-data-pending'); \
    localStorage.removeItem('item-cache-data-done'); \
    window.location.replace(
        document.location.origin);\
</script>\
</html>"

这样,我们不仅删除了用户令牌,还删除了所有项目和日期。 这样,一旦我们注销,我们的数据就安全了。

概括

在本章中,我们已经了解了 RESTful 设计的不同方面,并将它们实现到我们的应用程序中。 我们评估了应用程序的各个层,使我们能够重构中间件,以便根据结果处理两个不同的 future。 这不仅仅停留在授权请求上。 根据请求的参数,我们可以实现中间件将请求重定向到其他服务器,或者直接使用按需代码响应进行响应,对前端进行一些更改,然后进行另一个 API 调用。 这种方法为我们提供了另一种工具,即在视图被命中之前中间件中具有多种未来结果的自定义逻辑。

然后,我们重构了路径结构以使界面统一,防止前端和后端视图之间的冲突。

然后,我们探索了不同级别的日志记录并记录了我们的所有请求,以突出显示无声但不良的行为。 在重构我们的前端来纠正这个问题之后,我们使用日志记录来评估我们的缓存机制在将待办事项缓存到前端时是否正常工作,以防止过多的 API 调用。 现在,我们的应用已经可以通过了。 我们总是可以做出改进; 然而,我们还没有达到这样的阶段:如果我们将应用程序部署到服务器上,我们就能够监视它,在出现问题时检查日志,使用自己的待办事项列表管理多个用户,并拒绝 在未授权的请求到达视图之前。 我们还有缓存,并且我们的应用程序是无状态的,可以在 PostgreSQL 和 Redis 数据库上访问和写入数据。

在下一章中,我们将为 Rust 结构编写单元测试,为 API 端点编写功能测试,并清理代码以准备部署。

问题

为什么我们不能简单地将多个 future 编码到中间件中,只调用并返回考虑到请求参数和授权结果正确的那个,而必须将它们包装在一个枚举中?

如果我们的 API 为可能无法立即更新的移动应用程序和第三方提供服务,我们如何添加新版本的视图但仍然支持旧视图?

为什么无状态约束在弹性云计算时代变得更加重要?

我们如何利用 JWT 的属性来整合其他服务?

警告日志消息隐藏了用户发生错误的事实,但仍然提醒我们修复它。 为什么我们要费心告诉用户发生了错误并使用错误日志重试?

记录所有请求有什么好处?

为什么我们有时必须使用异步移动?

答案

Rust 的强类型系统会抱怨。 这是因为异步块的行为类似于闭包,这意味着每个异步块都是其自己的类型。 指向多个 future 就像指向多种类型,因此看起来我们正在返回多种不同的类型。

我们在视图目录中添加一个带有新视图的新模块。 它们具有相同的端点和视图以及所需的新参数。 然后我们可以在工厂函数中添加版本参数。 这些新视图将具有与 v2 相同的端点。 这使用户能够使用新旧 API 端点。 然后,当旧版本不再受支持时,我们会通知用户,让他们有时间进行更新。 在特定时间,我们会将构建中的版本移至 v2,删除所有调用 v1 的请求,并以不再支持 v1 的有用消息进行响应。 为了使此转换生效,我们必须将构建配置中允许的版本更新为受支持的版本列表。

借助编排工具、微服务和按需弹性计算实例,根据需求启动和关闭弹性计算实例正变得越来越常见。 如果我们将数据存储在实例本身上,那么当用户进行另一个 API 调用时,无法保证用户会访问同一个实例,从而获得不一致的数据读写。

JWT 令牌使我们能够存储用户 ID。 如果第二个服务具有相同的密钥,我们只需将请求传递到标头中带有 JWT 的另一个服务即可。 其他服务不必具有登录视图或对用户数据库的访问权限,并且仍然可以运行。

当发生错误导致我们无法追溯并解决问题时,我们必须提出错误而不是警告。 错误的一个典型示例是无法写入数据库。 警告的一个很好的例子是另一个服务没有响应。 当其他服务启动并运行时,我们可以进行数据库调用并调用该服务来完成该过程。

在生产中,故障排除时需要评估服务器的状态。 例如,如果用户没有遇到更新,我们可以快速检查日志以查看服务器是否确实正在接收请求,或者前端的缓存是否存在错误。 我们还可以使用它来查看我们的应用程序是否按照我们期望的方式运行。

我们在异步块中引用的变量的生命周期可能不够长,无法看到异步块的结尾。 为了解决这个问题,我们可以将变量的所有权转移到带有异步移动块的块。

最近发表
标签列表