Djnago-解耦教程-全-

Djnago 解耦教程(全)

原文:Decoupled Django

协议:CC BY-NC-SA 4.0

一、解耦世界简介

本章简要介绍了:

  • 单片和解耦架构

  • REST 架构

  • GraphQL 查询语言

在这一章中,我们回顾了传统的 web 应用,基于视图、模型和控制器的经典 MVC 模式。

我们开始概述解耦架构的用例、优点和缺点。我们探索 REST 的基础,看看它与 GraphQL 的比较,并了解 REST APIs 毕竟不仅仅是 RESTful 的。

独石和 MVC

至少二十年来,传统的网站和应用都共享一个基于模型-视图-控制器模式的通用设计,缩写为 MVC。

这种模式不是一天建成的。一开始,业务逻辑、HTML 和我们今天所知的 JavaScript 的苍白模仿交织在一起。在典型的 MVC 安排中,当用户请求一个网站的路径时,应用用一些 HTML 来响应。在幕后,一个控制器,通常是一个函数或方法,负责将适当的视图返回给用户。这发生在控制器通过 ORM ( 对象关系映射)用来自数据库层的数据填充视图之后。这样一个系统,作为一个整体为用户服务,所有的组件都在一个地方,被称为 monolith 。在一个单一的 web 应用中,HTML 响应是在将页面返回给用户之前生成的,这个过程被称为传统的服务器端呈现。图 1-1 显示了 MVC 的一个表示。

img/505838_1_En_1_Fig1_HTML.png

图 1-1

MVC 应用用一个由控制器生成的视图来响应用户。模型层提供来自数据库的数据

MVC 有一些变化,比如 Django 使用的模型-视图-模板模式。在 Django 的 MVT 中,数据仍然来自数据库,但是视图的作用就像一个控制器:它通过 ORM 从数据库中获取数据,并将结果注入模板,然后返回给用户。MVC 和它的变体仍然存在:所有最流行的 web 框架都像。NET core、Rails、Laravel 和 Django 本身都成功地采用了这种模式。然而,最近我们看到了基于面向服务架构的解耦应用的传播。

在这种设计中,RESTful 或 GraphQL API 为一个或多个 JavaScript 前端、移动应用或另一台机器公开数据。面向服务和解耦架构是一个更广泛的类别,包含了一系列的微服务系统。在整本书中,我们在 web 应用的上下文中提到解耦架构,主要是指后端有 REST API 或 GraphQL,前端有独立的 JavaScript/HTML 的系统。在关注 REST APIs 之前,让我们首先解开解耦架构背后的东西。

解耦架构是由什么构成的?

一个解耦架构是一个遵守软件工程中最重要的规则之一的系统:关注点分离

在一个解耦的架构中,客户端和服务器之间有一个清晰的分离。这也是 REST 要求的最重要的约束之一。图 1-2 显示了这样一个系统的概况,包括一个后端和一个前端。

img/505838_1_En_1_Fig2_HTML.png

图 1-2

使用 REST API 作为 JavaScript/HTML 前端数据源的解耦应用

正如您将在本书后面看到的,客户机和服务器、视图和控制器之间的这种分离并不总是严格的,并且根据解耦方式的不同,这种区别会变得模糊。例如,我们可以让 REST API 和前端生活在两个完全不同的环境中(单独的域或者不同的起源)。在这种情况下,分工非常明确。在某些情况下,当完整的 JavaScript 前端没有意义时,Django 仍然可以公开 REST 或 GraphQL API,并将 JavaScript 嵌入 Django 模板中,与端点进行对话。

更糟糕的是,像 Angular 这样的框架甚至在构建前端代码时也采用了模型-视图-控制器模式。在单页应用中,我们可以找到相同的 MVC 设计,它复制了后端结构。您可能已经猜到,在某种程度上,纯解耦架构的缺点之一是代码重复。定义了什么是解耦架构之后,现在让我们来谈谈它的用例。

为什么以及何时解耦?

这不是一本关于 JavaScript 淘金热的书。事实上,你应该在考虑完全重写你心爱的 Django monolith 之前权衡你的选择。

不是每个项目都需要单页应用。相反,如果您的应用属于以下类别之一,您可以开始评估解耦架构的优势。这里列出了最常见的使用案例:

  • 机器对机器通信

  • 带有大量 JS 驱动的交互的交互式仪表盘

  • 静态站点生成

  • 移动应用

使用 Django,您可以构建各种各样涉及机器对机器通信的东西。想象一个从传感器收集数据的工业应用,这些数据可以在以后汇总成各种数据报告。这样的仪表板可以有很多 JS 驱动的交互。解耦架构的另一个有趣的应用是内容存储库。像 Django 这样的 Monoliths,Drupal 这样的 CMS,或者 WordPress 这样的博客平台都是静态站点生成器的好伙伴。我们稍后将详细探讨这个主题。

分离架构的另一个好处是能够服务不同类型的客户端:移动应用是最引人注目的用例之一。现在,如果解耦架构听起来太有吸引力,我建议您考虑它们的缺点。专门基于单页面应用的解耦架构对于以下情况并不总是有效的选择:

  • 受约束的团队

  • 很少或没有 JS 驱动交互的网站

  • 受限设备

  • 注重搜索引擎优化的内容密集型网站

Note

正如你将在第七章中看到的,像 Next.js 这样的框架可以通过生成静态 HTML 来帮助搜索引擎优化单页应用。使用这种技术的框架的其他例子有 Gatsby 和 Prerenderer。

现代前端开发很容易让人不知所措,尤其是如果团队很小的话。从零开始设计或构建解耦架构时,最严重的障碍之一是隐藏在 JavaScript 工具背后的巨大复杂性。在接下来的小节中,我们将重点关注 REST 和 GraphQL,这是解耦架构的两大支柱。

超媒体所有的东西

几乎所有解耦前端架构的基础都是 REST 架构风格。

如今,休息几乎不是一个新奇的概念。理论是,通过动词或命令,我们在系统上创建、检索或修改资源。例如,给定后端的一个User模型,由 REST API 作为资源公开,我们可以通过一个GET HTTP 请求获得数据库中所有实例的集合。下面显示了一个典型的GET请求来检索实体列表:

GET https://api.example/api/users/

如您所见,在检索资源时,我们说users,而不是user。按照惯例,资源应该总是复数。为了从 API 中检索单个资源,我们在路径中传递 ID,作为一个路径参数。下面显示了一个GET对单个资源的请求:

GET https://api.example/api/users/1

表 1-1 显示了所有动词(HTTP 方法)的分类及其对资源的影响。

表 1-1

对后端的给定资源产生相应影响的 HTTP 方法

|

方法

|

影响

|

幂等

|
| --- | --- | --- |
| POST | 创建资源 | 不 |
| GET | 检索资源 | 是 |
| PUT | 更新资源 | 是 |
| DELETE | 删除资源 | 是 |
| PATCH | 部分更新资源 | 不 |

为了引用这组 HTTP 方法,我们还使用了术语 CRUD ,它代表创建、读取、更新和删除。从表中可以看出,有些 HTTP 动词是等幂,意思是操作的结果总是稳定的。例如,一个GET请求总是返回相同的数据,无论我们在第一次请求后发出多少次命令。

相反,一个POST请求总是会导致一个副作用,即在后端创建一个新的资源,每个调用有不同的值。当使用GET检索资源时,我们可以在查询字符串中使用搜索参数来指定搜索约束、排序或限制结果的数量。下面显示了一组有限用户的请求:

GET https://api.example/api/users?limit=20

当用POST创建一个新资源时,我们可以发送一个请求体和请求。根据操作类型,API 可以用 HTTP 状态代码和新创建的对象进行响应。HTTP 响应代码的常见例子有200 OK201 Created202 Accepted。当事情进展不顺利时,API 可能会用一个错误代码来响应。HTTP 错误代码的常见例子有500 Internal Server Error403 Forbidden401 Unauthorized

客户机和服务器之间的这种来回通信通过 HTTP 协议传送 JSON 对象。如今,JSON 是交换数据的首选格式,而在过去,您可以在 HTTP 上看到 XML(SOAP 架构现在仍然存在)。REST 为什么遵循这些约定,为什么使用 HTTP?当 Roy Fielding 在 2000 年写他的题为“架构风格和基于网络的软件架构的设计”的论文时,他定义了以下规则:

  • 作为引擎的超媒体:当请求一个资源时,来自 API 的响应也必须包括到相关实体或其他动作的超链接。

  • 客户机-服务器分离:消费者(JavaScript、机器或通用客户机)和 Web API 必须是两个独立的实体。

  • 无状态:客户端和服务器之间的通信不应该使用服务器上存储的任何数据。

  • 可缓存的:API 应该尽可能地利用 HTTP 缓存。

  • 统一接口(Uniform interface):客户端和服务器之间的通信应该使用相关资源的表示,以及标准的通信语言。

为了更深入地了解这些规则,我们有必要走一个捷径。

作为引擎的超媒体

在最初的论文中,这个约束隐藏在统一接口部分,但是它对于理解 REST APIs 的真正本质是至关重要的。

超媒体作为引擎的实际含义是,当与 API 通信时,我们应该能够通过检查响应中的任何链接来了解下一步是什么。Django REST framework 是 Django 中构建 REST APIs 最流行的框架,它使得构建超媒体 API变得很容易。事实上,Django REST 框架序列化器有能力返回超链接资源。例如,对一个List模型的查询可以返回一对多关系的方。清单 1-1 展示了来自 API 的 JSON 响应,其中Card模型通过外键连接到List

{
  "id": 8,
  "title": "Doing",
  "cards": [
      "https://api.example/api/cards/1",
      "https://api.example/api/cards/2",
      "https://api.example/api/cards/3",
      "https://api.example/api/cards/4"
  ]
}

Listing 1-1A JSON Response with Hyperlinked Relationships

超链接资源的其他例子是分页链接。清单 1-2 是对boards资源(Board模型)的 JSON 响应,带有在结果间导航的超链接。

{
  "id": 4,
  "title": "Doing",
  "next": "https://api.example/api/boards/?page=5",
  "previous": "https://api.example/api/boards/?page=3"
}

Listing 1-2A JSON Response with Pagination Links

Django REST 框架的另一个有趣的特性是可浏览 API,这是一个用于与 REST API 交互的 web 接口。所有这些特性使得 Django REST 框架超媒体 API 准备就绪,这是这些系统的正确定义。

客户机-服务器分离

第二个约束,客户机-服务器分离,很容易实现。

REST API 可以公开端点,消费者可以连接到这些端点来检索、更新或删除数据。在我们的例子中,消费者将是 JavaScript 前端。

无国籍的

一个兼容的 REST API 应该是无状态的。

无状态意味着在客户机和服务器通信期间,请求不应该使用存储在服务器上的任何上下文数据。这并不意味着我们不能与 REST APIs 公开的资源进行交互。该约束适用于会话数据,如存储在服务器上的会话 cookies 或其他标识方式。这个严格的规定促使工程师们为 API 认证寻找新的解决方案。JSON Web Token,在本书后面被称为 JWT,就是这种研究的产物,它不一定比其他方法更安全,您将在后面看到。

可缓冲的

一个兼容的 REST API 应该尽可能地利用 HTTP 缓存。

HTTP 缓存通过 HTTP 头进行操作。一个设计良好的 REST API 应该总是给客户端提示一个GET响应的生命周期。为此,后端用一个max-age指令在响应上设置一个Cache-Control头,这决定了响应的生命周期。例如,要缓存一个小时的响应,服务器可以设置以下标头:

Cache-Control: max-age=3600

大多数时候,响应中还有一个ETag头,表示资源版本。清单 1-3 显示了一个带有缓存头的典型 HTTP 响应。

200 OK
Cache-Control: max-age=3600
ETag: "x6ty2xv"

Listing 1-3An HTTP Response with Cache Headers

Note

启用 HTTP 缓存的另一种方法涉及到Last-Modified头。如果服务器设置了这个头,客户端可以依次使用If-Modified-SinceIf-Unmodified-Since来检查资源的新鲜度。

当客户端请求相同的资源并且max-age还没有到期时,从浏览器的缓存中获取响应,而不是从服务器中获取。如果max-age已经过期,客户端通过附加If-None-Match头和来自ETag的值向服务器发出请求。这种机制被称为条件请求。如果资源仍然是新的,服务器用304 Not Modified响应,从而避免不必要的数据交换。相反,如果资源是陈旧的,也就是说,它已经过期,服务器会用一个新的响应进行响应。请记住,浏览器只缓存以下响应代码,这一点很重要:

  • 200 OK

  • 301 Moved Permanently

  • 404 Not Found

  • 206 Partial Content

此外,默认情况下,设置了Authorization头的响应不会被缓存,除非Cache-Control头包含了public指令。此外,正如您稍后将看到的,GraphQL 主要处理POST请求,默认情况下不会缓存这些请求。

统一界面

统一接口是 REST 最重要的规则之一。

它的原则之一,表示,规定客户端和服务器之间的通信,例如在后端创建一个新的资源,应该携带资源本身的表示。这意味着,如果我想在后端创建一个新资源,并发出一个POST请求,我应该提供一个包含该资源的有效负载。

假设我有一个 API,它接受一个端点上的命令,但是没有请求体。仅基于针对端点发出的命令创建新资源的 REST API 不是 RESTful 的。如果我们用统一接口和表示来代替,当我们想在服务器上创建一个新的资源时,我们在请求体中发送资源本身。清单 1-4 展示了一个投诉请求,请求主体用于创建一个新用户。

POST https://api.example/api/users/

{
  "name": "Juliana",
  "surname": "Crain",
  "age": 44
}

Listing 1-4A POST Request

这里我们使用 JSON 作为媒体类型,使用资源的表示作为请求体。统一接口也指用于驱动从客户端到服务器的通信的 HTTP 动词。当我们与一个 REST API 对话时,主要使用五种方法:GETPOSTPUTDELETEPATCH。这些方法也是统一接口,即我们用于客户端-服务器通信的通用语言。在回顾了 REST 原则之后,现在让我们把注意力转向它所谓的竞争者 GraphQL。

GraphQL 简介

GraphQL 出现在 2015 年,由脸书提出,并作为 REST 的替代品上市。

GraphQL 是一种数据查询语言,它允许客户端精确地定义从服务器获取什么数据,并在一个请求中组合来自多个资源的数据。在某种意义上,这就是我们对 REST APIs 的一贯做法,但是 GraphQL 更进一步,将更多的控制权推给了客户端。我们看到了如何从 REST API 请求数据。例如,为了获得单个用户,我们可以访问以下虚构的 REST API 的 URL:

https://api.example/api/users/4

作为响应,API 返回给定用户的所有字段。清单 1-5 显示了单个用户的 JSON 响应,这也恰好与Friend模型有一对多的关系。

{
  "id": 4,
  "name": "Juliana",
  "surname": "Crain",
  "age": 44,
  "city": "London",
  "occupation": "Software developer",
  "friends": [
      "https://api.example/api/friend/1",
      "https://api.example/api/friend/2",
      "https://api.example/api/friend/3",
      "https://api.example/api/friend/4"
  ]
}

Listing 1-5A JSON Response from a REST API

这是一个虚构的例子,但是如果您想象一下响应中有一组更大的字段,那么很明显我们正在过量获取,也就是说,我们正在请求比我们需要的更多的数据。如果我们考虑一下同一个 API,这次是用 GraphQL 实现的,我们可以请求一个更小的字段子集。为了从 GraphQL API 请求数据,我们可以进行一个查询。清单 1-6 显示了一个典型的 GraphQL 查询,通过 ID 请求一个用户,只有一个字段子集。

query {
    getUser(userID: 4) {
        surname,
        age
    }
}

Listing 1-6A GraphQL Query

如您所见,客户端控制它可以请求的字段。例如,这里我们跳过了除surnameage之外的所有字段。这个查询还有一个由userID标识的参数,它充当查询的第一个过滤器。为了响应这个查询,GraphQL API 返回请求的字段。清单 1-7 显示了我们查询的 JSON 响应。

{
  "surname": "Crain",
  "age": 44
}

Listing 1-7A JSON Response from the Previous Query

“不再过度提取”是 GraphQL 优于 REST 的主要卖点之一。实际上,这种基于字段的过滤功能并不是 GraphQL APIs 独有的。例如,遵循 JSON API 规范的 REST APIs 可以使用稀疏字段集来请求数据的一个子集。一旦我们对 GraphQL 端点发出一个查询,这个查询就作为请求体通过一个POST请求。清单 1-8 显示了一个对 GraphQL API 的请求。

POST https://api.example/graphql

{
"query" : "query { getUser(userID: 4) { surname, age } }",
"variables": null
}

Listing 1-8A GraphQL Query over a POST Request

您已经注意到,在这个请求中,我们调用了/graphql端点,而不是/api/users/4。同样,我们使用POST而不是GET来检索资源。这与 REST 架构风格有很大的不同。GraphQL 中的查询请求只是故事的一半。REST 分别使用POSTPUTDELETE来创建、更新或删除资源,而 GraphQL 使用突变的概念来改变数据。清单 1-9 显示了创建新用户的一个变种。

mutation {
   createUser(name: "Caty", surname: "Jonson") {
       name,
       surname
   }
}

Listing 1-9A GraphQL Mutation

订阅是 GraphQL 服务的另一个有趣的特性。客户端可以订阅事件。例如,我们可能希望在新用户注册我们的服务时收到来自服务器的通知。在 GraphQL 中,我们为此注册了一个订阅。清单 1-10 说明了一个订阅。

subscription {
   userRegistered {
       name,
       email
   }
}

Listing 1-10A GraphQL Subscription

当 GraphQL 查询到达后端时会发生什么?该流程与 REST API 相比如何?一旦查询到达后端,它将根据包含类型定义模式进行验证。然后一个或多个解析器,连接到模式中每个字段的专用函数,集合并为用户返回适当的数据。说到类型定义,GraphQL 中的一切都是类型:查询、变异、订阅和域实体。每个查询、变异和实体在使用之前都必须在模式中定义,用模式定义语言编写。清单 1-11 显示了一个简单的查询模式。

type User {
      name: String,
      surname: String,
      age: Int,
      email: String
}

type Query {
      getUser(userID: ID): User!
}

type Mutation {
      createUser(name: String, surname: String): User
}

type Subscription {
      userRegistered: User
}

Listing 1-11A Simple GraphQL Schema

通过查看这个模式,您可以立即注意到 GraphQL 是如何实施强类型的,这很像 TypeScript 或 C#之类的类型化语言。这里,StringIntID标量类型,而User是我们的自定义类型。按照 GraphQL 的说法,这些自定义类型属于对象类型的定义。GraphQL 如何适应 Python 生态系统?如今,有许多用于构建 Pythonesque GraphQL APIs 的库。最受欢迎的如下:

  • 石墨烯,以其代码优先的方式构建 GraphQL 服务

  • Ariadne,一个模式优先的 GraphQL 库

  • Strawberry,建立在数据类之上,代码优先,有类型提示

所有这些库都集成了 Django。GraphQL 的代码优先方法和模式优先方法之间的区别在于,前者将 Python 语法提升为编写模式的一等公民。后者使用多行 Python 字符串来表示它。在第 10 和 11 章中,我们与 Django 的 GraphQL、Ariadne 和 Strawberry 进行了广泛的合作。

摘要

本章回顾了传统架构和解耦架构的基础。你学到了:

  • 单片系统是作为一个整体单元向用户提供 HTML 和数据的系统

  • REST APIs 实际上是超媒体 API,因为它们使用 HTTP 作为通信媒介,并使用超链接提供相关资源的路径

  • JavaScript 优先和单页应用并不是所有用例的完美解决方案

  • GraphQL 是 REST 的有力竞争者

在下一章,我们将深入 JavaScript 生态系统,看看它是如何适应 Django 的。

额外资源

二、JavaScript 遇上 Django

本章涵盖:

  • JavaScript 如何融入 Django

  • JavaScript 生态系统和工具

  • JavaScript 前端库和框架

尽管 JavaScript 被认为是一种玩具语言,但近年来它已经成为一种成熟的工具。

不管是好是坏,如今 JavaScript 无处不在。JavaScript 工具也呈指数增长,新的库和技术快速涌入生态系统。在这一章中,我们将介绍现代 JavaScript 场景。您将理解 JavaScript 工具是做什么的,以及它们如何适应 Django。我们还看一下最流行的前端库和框架。

生产中的 JavaScript 和 Django

为了更好地理解现代 JavaScript 如何融入 Django,我们不仅要考虑本地开发环境,而且首先要考虑典型的生产环境。

生产中的 Django 与发展中的 django 完全不同。首先,开发中的 Django 有一个本地服务器,可以提供静态文件,即 JavaScript、图像和 CSS。然而,同一个开发服务器不能处理生产负载,更不用说生产设置的安全性了。因此,在生产环境中,我们通常采用以下组件:

  • Django 项目本身

  • 一个反向代理,比如 NGINX,服务于静态文件,它也充当 SSL 终端

  • 一个 WSGIASGI 服务器,比如 Gunicorn、Uvicorn 或 Hypercorn(下一章将详细介绍 ASGI)

JavaScript 如何融入其中?当我们在生产服务器上部署 Django 时,我们运行python manage.py collectstatic将所有 Django 应用的静态文件分组到一个地方,由STATIC_ROOT配置变量标识。为了便于理解,假设我们有一个 Django 项目,它有一个名为quote的应用和一个位于~/repo-root/quote/static/quote/js/index.js中的 JavaScript 文件。假设我们对STATIC_ROOT进行了如下配置,其中/home/user/static/是生产服务器上的一个现有文件夹:

STATIC_ROOT = "/home/user/static/"

当我们运行python manage.py collectstatic时,静态文件在/home/user/static/中着陆,准备好被任何引用static模板标签的 Django 模板获取。为此,STATIC_URL配置必须指向用于提供静态文件的 URL。在我们的例子中,我们想象一个名为static.decoupled-django.com的子域:

STATIC_URL = "https://static.decoupled-django.com/"

这个 URL 通常由 NGINX 虚拟主机提供服务,有一个location块指向 Django 的STATIC_ROOT中配置的值。清单 2-1 展示了如何从 Django 模板中调用静态文件(这里是 JavaScript)。

{% load static %}
<!DOCTYPE html>
<html lang="en">
<body>
<h1>Hello Django!</h1>
<div id="root"></div>
</body>
<script src="{% static "quote/js/index.js" %}"></script>
</html>

Listing 2-1A Django Template Referencing a Static File

在实际的 HTML 中,URL 变成了:

<script src="https://static.decoupled-django.com/quote/js/index.js"></script>

这是最简单的情况,我们有一个或多个 Django 应用,每个都有自己的 JavaScript 文件。这种方法适用于小型应用,其中应用的入口点的 JavaScript 代码符合 200KB 的限制。这里的入口点指的是浏览器启动整个应用必须下载的第一个 JavaScript 文件。由于这本书是关于“解耦的 Django ”,我们需要考虑更复杂的设置,其中提供给用户的 JavaScript 负载可能超过 200KB。此外,JavaScript 应用越大,我们就越需要以模块化的方式构建代码,这就引出了 JavaScript ES 模块和模块捆绑器

对模块捆扎机的需求

直到 2015 年,JavaScript 在前端还没有一个标准的模块系统。尽管 Node.js 从一开始就有require(),但情况实际上分散在前端,有不同的竞争方法,如 AMD modules、UMD 和 CommonJS。

终于在 2015 年, ES 模块登陆 ECMAScript。ES 模块提供了 JavaScript 中代码重用的标准方法,同时也支持像动态导入这样的强大模式来提高大型应用的性能。现在,一个典型的前端项目的问题是,ES 模块不是开发人员唯一可用的资产。有图像、样式文件(如 CSS 或 SASS)以及不同类型的 JavaScript 模块。我们也不要忘记 ES 模块是一个相当新的产品,传统的模块格式仍然存在。一个 JavaScript 项目可能使用基于 es 模块的新库,但是也需要包含作为 CommonJS 发布的代码。此外,ES 模块不被较旧的浏览器支持。

现代前端开发人员面临的另一个挑战是典型 JavaScript 应用的大小,尤其是当项目需要大量依赖项时。为了克服这些问题,被称为模块捆绑器的专门工具出现了。模块捆绑器的目标是多方面的。该工具可以:

  • 将不同类型的 JavaScript 模块组装到同一个应用中

  • 在 JavaScript 项目中包含不同类型的文件和资源

  • 使用一种称为代码分割的技术来提高应用的性能

简而言之,模块捆绑器提供了一个统一的接口,用于收集前端项目的所有依赖项,组装它们,并生成一个或多个名为 bundles 的 JavaScript 文件,以及最终应用的任何其他资产(CSS 和图像)。最近最流行的模块捆绑器之一是 webpack ,它也被用于 JavaScript 领域最重要的项目搭建 CLI 工具(create-react-app,Vue CLI)。在下一节中,我们将探讨为什么 webpack 对于需要处理大量 JavaScript 的 Django 开发人员来说很重要。

Webpack 对抗 Django(代码拆分的需要)

JavaScript 中的代码分割指的是向客户端提供尽可能少的 JavaScript 代码,同时按需加载其余代码的能力。

对于普通的 Django 开发人员来说,理解代码分割并不是绝对必要的,但是参与需要在前端进行大量交互的中型到大型 Django 项目的 Python 团队必须了解这个概念。在前面的小节中,我提到了 JavaScript 应用入口点的理论极限为 200KB。接近这个数字,我们可能会提供糟糕的导航体验。JavaScript 对于任何设备都是有成本的,但是在低端设备和慢速网络上,性能下降会更加明显(我建议一直关注 Addy Osmani 的“JavaScript 的成本”,参见参考资料中的链接)。由于这个原因,对最终的工件应用一系列的技术是非常重要的。一种这样的技术是代码缩减**,其中最终的 JavaScript 代码去掉了注释、空格和while函数,变量名被破坏。这是一个众所周知的优化,几乎任何工具都可以完成。但是一种更强大的技术,不包括现代的模块捆绑器,叫做代码分割,可以进一步缩小生成的 JavaScript 文件。JavaScript 应用中的代码拆分适用于各种级别:

** 在路由级别

  • 在组件级别

  • 关于用户交互(动态导入)

在某种程度上,像 Vue CLI 和 create-react-app 这样的 CLI 工具已经在代码分割方面提供了现成的默认设置。在这些工具中,webpack 已经被配置为产生高效的输出,这要归功于一种被称为供应商分裂的基本形式的代码分裂。在下面的例子中可以看到代码分割对 JavaScript 应用的影响。这是在配置为单页面应用的最小项目上运行npm run build的结果:

js/chunk-vendors.468a5298.js
js/app.24e08b96.js

应用的后续部分,称为,位于不同于主入口点的文件中,并且可以并行加载。你可以看到我们有两个文件,app.24e08b96.jschunk-vendors.468a5298jsapp.24e08b96.js文件是应用的入口点。当应用加载时,入口点需要第二个块,名为chunk-vendors.468a5298.js。当你在一个块名中看到供应商时,这表明 webpack 正在进行最基本的代码拆分:供应商拆分。供应商依赖是像 lodash 和 React 这样的库,它们可能包含在项目的多个地方。为了防止依赖性重复,可以指示 webpack 识别依赖性的消费者之间的共同点,并将共同的依赖性分割成单个块。从这些文件名中您可以注意到的另一件事是散列。例如在app.24e08b96.js中,hash 是24e08b96,由模块捆绑器从文件内容中计算出来。当文件内容改变时,散列也会改变。要记住的重要一点是,入口点和块在脚本标签中出现的顺序对于应用的运行至关重要。清单 2-2 展示了我们的文件应该如何出现在 HTML 标记中。

<-- rest of the document -->
<script src=/js/chunk-vendors.468a5298.js></script>
<script src=/js/app.24e08b96.js></script>
<-- rest of the document -->

Listing 2-2Two Chunks As They Appear in the HTML

这里,chunk-vendors.468a5298.js必须在app.24e08b96.js之前,因为chunk-vendors.468a5298.js包含一个或多个对入口点的依赖。继续关注 Django,您可以想象,为了以相同的顺序注入这些块,我们需要一些系统来将每个文件的出现顺序与模板中的static标签配对。一个名为django-webpack-loader的 Django 库旨在简化 Django 项目中 webpack 的使用,但是当 webpack 4 推出新的代码分割配置时,splitChunksdjango-webpack-loader停止了工作。

这里的要点是 JavaScript 工具比其他任何东西都移动得快,包维护者要跟上最新的变化并不容易。此外,弄乱 webpack 配置是一种奢侈,不是每个人都能负担得起的,还不算配置漂移和破坏性更改的风险。如果有疑问,在使用 webpack 或接触其配置之前,使用这个小启发来决定要做什么:如果应用的 JavaScript 部分超过 200KB,使用适当的 CLI 工具,并将应用作为 Django 模板中的单页应用,或作为解耦的 SPA。我们将在第五章中探讨第一种方法。如果 JavaScript 代码符合 200KB 的限制,并且交互的数量很少,那么使用一个简单的<script>标签来加载您需要的内容,或者如果您想使用现代的 JavaScript,那么至少配置一个简单的 webpack 管道,并进行供应商拆分。概述了模块捆绑器的基础之后,现在让我们继续我们的现代 JavaScript 工具之旅。

Note

JavaScript 工具,尤其是 webpack,是一个太多的移动目标,在一本书中涵盖它会有提供过时指令的风险。出于这个原因,我在这里不讨论 webpack 项目的设置。您可以在参考资料中找到这种设置示例的链接。

现代 JavaScript、Babel 和 Webpack

作为开发人员,我们很幸运,因为大多数时候我们可以使用快速的互联网连接、强大的多核机器、大量的 RAM 和现代浏览器。

如果这个闪亮的新 JavaScript 片段可以在我的机器上运行,那么它应该可以在任何地方运行,对吗?很容易理解编写现代 JavaScript 的吸引力。考虑以下基于 ECMAScript 5 的示例:

var arr = ["a", "b"];
function includes(arr, element) {
  return arr.indexOf(element) !== -1;
}

这个函数检查一个给定的元素是否存在于一个数组中。它基于Array.prototype.indexOf(),这是一个内置的数组函数,如果在目标列表中没有找到给定的元素,它将返回-1。现在转而考虑基于 ECMAScript 2016 的以下片段:

const arr = ["a", "b"];
const result = arr.includes("c");

第二个例子显然更简洁,更容易理解,也更适合开发人员。缺点是老一点的浏览器看不懂Array.prototype.includes()或者const。我们不能按原样发送此代码。

Tip

caniuse.comdeveloper.mozilla.org的兼容性表都是非常宝贵的资源,可以用来了解给定的目标浏览器是否支持现代语法。

幸运的是,越来越少的开发者需要担心可怕的 Internet Explorer 11,但仍然有许多边缘情况需要考虑。到目前为止,最兼容的 JavaScript 版本是 ECMAScript 2009 (ES5),这是一个安全的目标。为了让 JavaScript 开发者和用户都满意,社区提出了一类叫做 transpilers 的工具,其中 Babel 是最受欢迎的化身。有了这样的工具,我们可以编写现代的 JavaScript 代码,将它传递到一个转换/编译管道中,并最终得到兼容的 JavaScript 代码。在典型的设置中,我们配置一个构建管道,其中:

  1. Webpack 吸收了用现代 JavaScript 编写的 ES 模块。

  2. webpack 加载器通过 Babel 传递代码。

  3. 巴贝尔破译了密码。

webpack/Babel duo 现在无处不在,被 create-react-app、Vue CLI 等等使用。

打字稿上的一句话

对于大多数开发人员来说,TypeScript 是房间里的大象。

TypeScript 作为 JavaScript 的静态类型化下降,更类似于 C#或 Java 这样的语言。它在 Angular 世界中广泛存在,并且正在征服越来越多的 JavaScript 库,这些库现在默认带有类型定义。不管你喜不喜欢 TypeScript,它都是一个需要考虑的工具。在第 8 、 11 和 12 章中,我们在 React 中使用打字稿。

JavaScript 前端库和框架

多年来,JavaScript 领域发生了巨大的变化。jQuery 仍然拥有很大的市场份额。

但是当涉及到客户端应用时,这些都是用 React 和 Vue.js 等现代前端库或 Angular 等成熟的框架编写或重写的。Django 主要由 HTML 模板支持,但是一旦时机成熟,它可以与几乎任何 JavaScript 库配对。如今,这一领域由三个竞争者主导:

  • React,来自脸书的 UI 库,它普及了(但不是首创)基于组件的界面编写方法

  • Vue.js,来自前 Angular 开发者尤雨溪的渐进式 UI 库,因其进步性而大放异彩

  • Angular,包含电池的框架,基于 TypeScript

在这三人中,Vue.js 是最进步的。Angular 包含更多的电池(就像 Django 一样),但有一个陡峭的学习曲线。相反,React 是最自由的,因为它没有对开发人员施加任何约束。你可以选择任何你需要的库。不管这是不是一个优势,我把意见留给你。要记住的重要一点是,核心 UI 库只是解决另一组问题的许多依赖项的起点,这些问题是在编写更大的客户端应用时出现的。特别是,您迟早会需要:

  • 国家管理图书馆

  • 路由库

  • 模式验证库

  • 表单验证库

每个 UI 库都有自己的附属子库来处理上述问题。React 依靠 Redux 或 Mobx(最近也依靠反冲. js)进行状态管理,依靠 React Router 进行路由。Vue.js 使用 Vuex 进行状态管理,使用 Vue 路由器进行路由。Angular 有很多不同的状态管理方法,但是 NgRx 是最广泛使用的。最终,所有这些库和框架都可以作为 Django 的外部客户端很好地工作,或者成对出现:

  • 客户端应用从 Django REST/GraphQL API 获取数据

  • 使用 Django 作为内容源的服务器端呈现或静态站点生成器

我们将在本书后面更详细地探讨这两个主题。在下一节中,我们快速看一下传统单页方法的一些替代方法。

轻量级 JavaScript UI 库

除了 Angular、Vue、React 和 Svelte,还有越来越多的轻量级 JavaScript 迷你框架,它们旨在简化前端最普通的任务,并提供足够的 JavaScript 来运行。

在这一类别中,我们可以提及以下工具:

  • (Alpines)人名

  • 热线的

  • 断续器

Hotwire 是由 Ruby on Rails 及其创造者 David Heinemeier Hansson 推广的一套工具和技术。在撰写本文时,有一项名为 turbo-django 的实验性工作,旨在将这些技术移植到 django 中。同样,还有一个新的 Django 框架叫做 django-unicorn 。所有这些工具都提供了一种不太依赖 JavaScript 的方法来构建交互式界面。一旦它们开始在野外获得牵引力,它们将值得一看。

通用 JavaScript 应用

Node.js 是一个在浏览器之外运行 JavaScript 代码的环境。这意味着服务器和 CLI 工具。

我们在本章中提到的大多数工具都是基于 JavaScript 的,因为它们运行在命令行上,所以它们需要一个 JavaScript 环境,Node.js 提供了这个环境。现在,如果您将它与能够在任何 JavaScript 环境中运行的前端库结合起来,而不仅仅是浏览器(如 React 和 Vue.js),您将获得一种特殊的 JavaScript 工具,它通过以 JavaScript 为中心的方法在服务器端呈现中风靡一时。

当谈到 MVC web 框架时,我们已经在第一章提到了服务器端渲染。在传统的服务器端渲染中,HTML 和数据是由 Ruby、Python 或 Java 等服务器端语言生成的,而在以 JavaScript 为中心的服务器端渲染方法中,一切都是由 Node.js 上的 JavaScript 生成的。这对最终用户和开发人员意味着什么?基于 JavaScript 的客户端应用和服务器端呈现的应用之间的主要区别在于,后者在将 HTML 发送给用户或搜索引擎爬虫之前生成 HTML。与纯客户端应用相比,这种方法有许多优点:

  • 它改善了内容密集型网站的搜索引擎优化

  • 它提高了性能,因为主要的渲染工作都推给了服务器

相反,对于开发人员来说,通用 JavaScript 应用是代码重用的圣杯,因为所有东西都可以用一种语言 JavaScript 编写。这些工具背后的推理和使用它们的动机大致如下:

  • 我们已经有了一个大的客户端应用,我们希望改善它的性能,无论是对最终用户还是爬虫

  • 我们在前端和 Node.js 后端之间有很多公共代码,我们希望重用它们

然而,与任何技术一样,通用 JavaScript 应用也有自己的缺点。对于一个专注于 Python 的 Django 商店来说,维护一个基于 Node.js 的并行架构可能会很费力。这些设置需要 Node.js 服务器才能运行,这带来了维护负担和复杂性。像 Vercel 和 Netlifly 这样的平台简化了这些架构的部署,但是仍然需要记住一些事情。目前用于创建通用 JavaScript 应用的最流行的工具有:

  • Next.js for React

  • Nuxt.js 表示 vue . js

  • 角度通用

可能还有一百万种以上的工具。在第七章,我们重点介绍 Next.js。

静态站点生成器

虽然 Next.js 和 Nuxt.js 等工具提供的服务器端呈现方法确实很有趣,但在搜索引擎优化至关重要并且某些页面上很少或没有 JavaScript 驱动的交互的所有情况下(例如,想想博客),静态站点生成应该是首选。

使用 JavaScript 生成静态站点的当前场景包括:

  • 盖茨比(姓)

  • Next.js for React

  • Nuxt.js 表示 vue . js

  • 斯考利代表安格尔

Next.js 和 Nuxt.js 可以工作在两种模式下:服务器端渲染静态站点生成。为了从后端获取数据,这些工具提供了向 REST API 或 GraphQL 发出普通 HTTP 请求的接口。Gatsby 只使用 GraphQL,并不适合每个团队。

测试工具

整个章节 8 致力于测试 Django 和 JavaScript 应用。在这一节中,我们将简要介绍最流行的 JavaScript 测试工具。它们属于测试的传统分类。

单元测试:

  • 玩笑

端到端测试:

  • 柏树

  • 谷歌木偶师

  • 微软剧作家

对于单元测试和多个单元之间的集成测试,Jest 是迄今为止最流行的工具。它可以测试纯 JavaScript 代码,也可以测试 React/Vue.js 组件。对于端到端测试和功能测试,Cypress 是功能最全的测试运行程序,与 Django 配合得也很好,木偶师和剧作家也越来越受欢迎。说实话,Jest 和 Cypress 更像是现有测试库的包装器:Jest 构建在 Jasmine 之上,而 Cypress 构建在 Mocha 之上,因为它们从这些库中借用了大量方法。然而,与更传统的工具相比,它们的流行是由它们提供的流畅的测试 API 引发的。

其他辅助 JavaScript 工具

如果不提辅助的 JavaScript 工具,那将是我的失职,这对现代 JavaScript 开发人员来说是如此重要。

在 Python 和 JavaScript 领域,都有代码短句。对于 JavaScript 来说,ESLint 是最普遍的。然后我们有更漂亮的代码格式化程序。在纯 JavaScript 代码和设计系统的交叉点上,我们找到了 Storybook,一个构建设计系统的强大工具。Storybook 在 React 和 React Native 社区中广泛使用,但与最流行的前端库兼容,如 Vue 和 Svelte。与测试工具一起,linters、formatters 和 UI 工具为每个 JavaScript 和 Django 开发人员提供了一个强大的武器库。

摘要

本章探讨了 Django 和客户端应用的界限。您了解了:

  • 生产中的 JavaScript 和 Django

  • 模块捆绑器和代码分割

  • webpack 如何集成到 Django 中

  • JavaScript 工具整体

  • 通用 JavaScript 应用

在下一章,我们将介绍异步 Django 环境。

额外资源

三、现代 Django 和 Django REST 框架

本章涵盖:

  • Django REST 框架和 Django 并排

  • 异步 Django

我猜所有 Django 开发者都有一个共同的故事。他们构建了许多东西,并尝试了类似 Flask 的迷你框架方法,但最终,他们总是回到 Django,因为它固执己见,并且它提供了用 Python 构建全栈 web 应用的所有工具。Django REST 框架是遵循相同的实用方法的 Django 包。在这一章中,我们比较了 Django REST 框架和 Django,并探索了异步 Django 环境。

什么是 Django REST 框架?

Django REST 框架(简称 DRF)是一个用于构建 Web APIs 的 Django 包。

尽管 GraphQL 迅速普及,并且出现了 Starlette 和 FastAPI 等异步微框架,但 DRF 仍然为数以千计的 web 服务提供支持。DRF 与 Django 无缝集成,以补充其构建 REST APIs 的特性。特别是,它提供了一系列现成的组件:

  • 基于类的 REST 视图

  • viewster

  • 序列化程序

这一章并不打算作为初学者的 DRF 指南,但是花一些时间来浏览一下这个包的主要构件是值得的。在接下来的部分中,我们将探索这些组件,因为它们将是我们的解耦 Django 项目的第一部分的乐高积木。

Django 和 DRF 的阶级观

在构建 web 应用时,处理数据插入和数据列表的一些常见模式会反复出现。

以 HTML 表单为例。需要考虑三个不同的阶段:

  • 显示表单,可以是空的,也可以有初始数据

  • 验证用户输入并显示最终错误

  • 将数据保存到数据库

在我们的项目中反复复制粘贴相同的代码是愚蠢的。出于这个原因,Django 提供了一个方便的 web 开发中常见模式的抽象。这些类被命名为基于类的视图,或者简称为 CBV 。Django 的 CBV 的一些例子是CreateViewListViewDeleteViewUpdateViewDetailView。您可能已经注意到,这些类的命名与 CRUD 模式密切相关,这在 REST APIs 和传统 web 应用中很常见。特别是:

  • CreateViewUpdateView用于POST请求

  • ListViewDetailView用于GET请求

  • DeleteView对于DELETE个请求

Django REST 框架遵循相同的约定,并为 REST API 开发提供了一个广泛的基于类的视图工具箱:

  • CreateAPIView对于POST个请求

  • ListAPIViewRetrieveAPIView用于GET请求

  • DestroyAPIView对于DELETE个请求

  • UpdateAPIView对于PUTPATCH的请求

此外,您可以使用 cbv 的组合进行检索/删除操作,如RetrieveDestroyAPIView,或者检索/更新/销毁,如RetrieveUpdateDestroyAPIView。您将在解耦的 Django 项目中使用许多这样的 cbv 来加速最常见任务的开发,尽管 DRF 在 cbv 之上提供了一个更强大的层,称为视图集

Tip

关于 Django 中基于类的视图的完整列表,请参见ccbv.co.uk。关于 Django REST 框架,请参见cdrf.co

drf 中的 crud 查看器

在第一章中,我们回顾了作为 REST 主要构件之一的资源的概念。

在 MVC 框架中,对资源的操作由公开 CRUD 动词方法的控制器来处理。我们还澄清了 Django 是一个 MVT 框架,而不是 MVC。在 Django 和 DRF 中,我们使用基于类的视图,按照GETPOSTPUT等等来展示常见的 CRUD 操作。然而,Django REST 框架在基于类的视图上提供了一个聪明的抽象,称为视图集,这使得 DRF 看起来比以前更加“足智多谋”。清单 3-1 显示了一个视图集,特别是一个ModelViewSet

from rest_framework import viewsets
from .models import Blog, BlogSerializer

class BlogViewSet(viewsets.ModelViewSet):
   queryset = Blog.objects.all()
   serializer_class = BlogSerializer

Listing 3-1A ModelViewSet in DRF

这样的视图集免费为您提供了处理常见 CRUD 操作的所有方法。表 3-1 总结了视图集方法、HTTP 方法和 CRUD 操作之间的关系。

表 3-1

视图集方法、HTTP 方法和 CRUD 操作之间的关系

|

视图集方法

|

HTTP 方法

|

CRUD 操作

|
| --- | --- | --- |
| create() | POST | 创建资源 |
| list() / retrieve() | GET | 检索资源 |
| update() | PUT | U 更新资源 |
| destroy() | DELETE | 删除资源 |
| update() | PATCH | 部分更新资源 |

一旦有了视图集,就只需要用urlpatterns连接类了。清单 3-2 显示了前一视图集的urls.py

from .views import BlogViewSet
from rest_framework.routers import DefaultRouter

router = DefaultRouter()
router.register(r"blog", BlogViewSet, basename="blog")
urlpatterns = router.urls

Listing 3-2Viewset and Urlpatterns in Django REST

正如您所看到的,用最少的代码,您就拥有了 CRUD 操作的完整集合,以及相应的 URL。

模型、表单和序列化程序

用很少或根本不用代码创建页面和表单的能力让 Django 大放异彩。

例如,由于有了模型表单,从 Django 模型开始创建表单只需要几行代码,完成验证和错误处理,就可以包含在视图中了。当你很急的时候,你甚至可以组装一个CreateView,它正好需要三行代码(至少)来产生一个模型的 HTML 表单,附加到相应的模板上。如果 Django 模型表单是最终用户和数据库之间的桥梁,那么 Django REST 框架中的序列化器就是最终用户、我们的 REST API 和 Django 模型之间的桥梁。序列化器负责 Python 对象的序列化和反序列化,它们可以被认为是 JSON 的模型形式。考虑清单 3-3 中所示的模型。

class Quote(models.Model):
   client = models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
   proposal_text = models.TextField(blank=True)

Listing 3-3A Django Model

从这个模型,我们可以制作一个 DRF 模型序列化器,如清单 3-4 所示。

class QuoteSerializer(serializers.ModelSerializer):
   class Meta:
       model = Quote
       fields = ["client", "proposal_text"]

Listing 3-4A DRF Serializer

当我们到达 DRF 端点时,在任何输出显示给用户之前,序列化程序将底层模型实例转换成 JSON。反之亦然,当我们对 DRF 视图发出一个POST请求时,序列化程序会将我们的 JSON 转换成相应的 Python 对象,而不是在验证输入之前。序列化器也可以表达模型关系。在清单 3-4 中,Quote通过多对一关系连接到定制用户模型。在我们的序列化程序中,我们可以将这种关系公开为超链接,如清单 3-5 所示(还记得超媒体 API 吗?).

class QuoteSerializer(serializers.ModelSerializer):
   client = serializers.HyperlinkedRelatedField(
       read_only=True, view_name="users-detail"
   )

   class Meta:
       model = Quote
       fields = ["client", "proposal_text"]

Listing 3-5A DRF Serializer

这将产生清单 3-6 中所示的 JSON 输出。

[
   {
     "client": "https://api.example/api/users/1",
     "proposal_text": "Django quotation system"
   },
   {
     "client": "https://api.example/api/users/2",
     "proposal_text": "Django school management system"
   }
]

Listing 3-6A JSON Response with Relationships

在第六章中,我们使用序列化器来解耦我们的 Django 项目。概述了 DRF 的构建模块之后,现在让我们来探索异步 Django 的奇妙世界。

从 WSGI 到 ASGI

WSGI 是 web 服务器与 Python 通信的通用语言,也就是说,它是一种能够在 web 服务器(如 Gunicorn)和底层 Python 应用之间来回通信的协议。

正如第二章所预期的,Django 需要一个 web 服务器来在生产中高效运行。通常,像 NGINX 这样的反向代理充当最终用户的主要入口点。Python WSGI 服务器监听 NGINX 背后的请求,并充当 HTTP 请求和 Django 应用之间的桥梁。在 WSGI 中,所有的事情都是同步发生的,没有办法在不引入重大改变的情况下重写协议。这使得社区(对于这项巨大的工作,我们必须感谢 Andrew Godwin)编写了一个新的协议,称为 ASGI,用于在支持 ASGI 的 web 服务器下运行异步 Python 应用。为了异步运行 Django,我们将在下一节看到这意味着什么,我们需要一个支持异步的服务器。你可以选择达芙妮,Hypercorn,或 Uvicorn。在我们的例子中,我们将使用 Uvicorn。

异步 Django 入门

异步代码完全是关于非阻塞执行的。这是 Node.js 等平台背后的魔力,这些平台早在高吞吐量服务领域出现多年。

相反,在 Python 3.5 (2015)中的async/await到来之前,异步 Python 的前景一直是支离破碎的,有许多 pep 和竞争的实现。在 Django 3.0 之前,Django 中的异步是一个梦想,当时对上述 ASGI 的开创性支持进入了核心。异步视图(Django 3.1)是 Django 近年来最令人兴奋的新增功能之一。为了理解异步在 Django 和 Python 中解决了什么问题,考虑一个简单的 Django 视图。当用户到达这个视图时,我们从外部服务获取一个链接列表,如清单 3-7 所示。

from django.http import JsonResponse
import httpx

client = httpx.Client(base_url="https://api.valentinog.com/demo")

def list_links(_request):
    links = client.get("/sleep/").json()
    json_response = {"links": links}
    return JsonResponse(data=json_response)

Listing 3-7A Synchronous View Doing Network Calls

这应该会立即引发一个危险信号。它可以运行得很快,真的很快,或者永远无法完成,让浏览器挂起。由于 Python 解释器的单线程性质,我们的代码按顺序步骤运行。在我们看来,在 API 调用完成之前,我们不能向用户返回响应。事实上,我的链接 https://api.valentinog.com/demo/sleep/ 被配置为在返回结果之前休眠 10 秒钟。换句话说,我们的视线被挡住了。这里的httpx,我用来发出请求的 Python HTTP 客户机,被配置了一个安全超时,几秒钟后会抛出一个异常,但是并不是每个库都有这种安全措施。

任何 IO 绑定的操作都有可能导致资源匮乏或阻塞整个执行过程。传统上,为了在 Django 中解决这个问题,我们会使用一个任务队列,它是一个在后台运行的组件,拾取要执行的任务,并在稍后返回结果。Django 最受欢迎的任务队列是 Celery 和 Django Q。强烈建议将任务队列用于 IO 绑定的操作,如发送电子邮件、运行调度的作业、HTTP 请求,或者需要在多个内核上运行的 CPU 绑定的操作。Django 中的异步视图不能完全取代任务队列,特别是对于 CPU 受限的操作。例如 Django Q 使用 Python multiprocessing。相反,对于非关键的 IO 绑定操作,比如 HTTP 调用或发送电子邮件,Django 异步视图非常好。在最简单的情况下,您可以发送电子邮件或调用外部 API,而不会有阻塞用户界面的风险。那么异步 Django 视图中有什么呢?让我们用异步视图重写前面的例子,让httpx客户机在后台检索数据;见清单 3-8 。

from django.http import HttpResponse
import httpx
import asyncio

async def get_links():
   base_url = "https://api.valentinog.com/demo"
   client = httpx.AsyncClient(base_url=base_url, timeout=15)
   response = await client.get("/sleep")
   json_response = response.json()
   # Do something with the response or with the json
   await client.aclose()

async def list_links(_request):
   asyncio.create_task(get_links())
   response = "<p>Fetching links in background</p>"
   return HttpResponse(response)

Listing 3-8An Asynchronous View Doing Network Calls, This Time Safely

如果您从未使用过异步 Python 和 Django,那么这段代码中有一些新概念值得澄清。首先我们导入asyncio,我们和异步 Python 世界之间的桥梁。然后我们用async def声明第一个异步函数。在第一个函数get_links()中,我们使用异步httpx客户端,超时为 15 秒。因为我们将在后台运行这个调用,所以我们可以安全地增加超时。接下来,我们在client.get()前使用await。最后,我们用client.aclose()关闭客户端。为了避免资源处于开放状态,您还可以将异步客户端与异步上下文管理器一起使用。在这种情况下,我们可以重构到async with,如清单 3-9 所示。

async def get_links():
   base_url = "https://api.valentinog.com/demo"
   async with httpx.AsyncClient(base_url=base_url, timeout=15) as client:
       response = await client.get("/sleep")
       json_response = response.json()
       # Do something with the json ...

Listing 3-9Using an Asynchronous Context Manager

Tip

异步上下文管理器实现了__aenter____aexit__,而不是__enter____exit__

在第二个异步函数list_links(),我们的 Django 视图中,我们使用asyncio.create_task()在后台运行get_links()。这才是真正的新闻。async def在 Django 看来,从开发人员的角度来看,这是最显著的变化。相反,对用户来说,最明显的好处是,如果执行时间比预期的长,他们不必等待查看 HTML。例如,在我们之前设想的场景中,我们可以稍后用电子邮件消息将结果发送给用户。这是 Django 中异步视图最引人注目的用例之一。但这并没有停止。概括一下,现在异步 Django 已经成为一个事物,您可以做的事情有:

  • 在一个视图中高效地并行执行多个 HTTP 请求

  • 计划长期运行的任务

  • 与外部系统安全交互

在 Django 和 DRF 变成 100%异步之前还缺少一些东西 ORM 和 Django REST 视图不是异步的——但是我们将在我们的解耦项目中到处使用异步 Django 功能来实践。

竞争的异步框架和 DRF

在撰写本文时,Django REST 框架还不支持异步视图。

有鉴于此,使用 FastAPI 或 Starlette 之类的东西来构建异步 web 服务不是更好吗?Starlette 是由 DRF 创建者 Tom Christie 构建的 ASGI 框架。相反,FastAPI 构建在 Starlette 之上,为构建异步 Web APIs 提供了一流的开发工具。两者都是绿地项目的绝佳选择,幸运的是你不必选择,因为 FastAPI 可以在 Django 本身内部运行,这要感谢像django-ninja这样的实验项目,而我们在等待异步 DRF。

摘要

本章回顾了 Django REST 框架的基础,并介绍了如何运行一个简单的异步 Django 视图。你学到了:

  • 什么是 DRF 基于类的视图、视图集和序列化程序

  • 如何创建异步 Django 视图

  • 如何在独角兽下经营 django

在下一章,我们将详细分析解耦合 Django 的模式,而在第六章,我们将最终接触到 Django 和 JavaScript 前端。

额外资源

四、解耦架构的优点和缺点

在这一章中,我们概述了分离 Django 项目的各种方法。特别是,我们涵盖:

  • 混合架构

  • 基于 REST 和 GraphQL 的完全解耦架构

  • 两种风格的优缺点

到本章结束时,你应该能够辨别一种或多种解耦风格,并成功地应用到你的下一个 Django 项目中。

伪解耦 Django

伪解耦,或混合解耦 是一种用少量 JavaScript 扩充静态前端的方法;只要能让最终用户觉得事情有互动性和趣味性就够了。

在接下来的两节中,我们将通过研究两种不同的方法来讨论伪解耦设置的优点和缺点:无 REST 和有 REST。

无休止伪解耦

根据您编程的时间长短,您会开始注意到在构建 web 应用时有一类模式反复出现:数据获取和表单处理。

例如,在 Django 应用中可能有一个插入新数据的页面。如何处理数据插入取决于用户需求,但基本上有两种选择:

  • 用 Django 单独处理表单

  • 用 JavaScript 处理表单

Django 表单和模型表单能够很好地为您生成字段,但是大多数时候我们希望拦截表单处理的经典GET / POST /Redirect 模式,尤其是表单的submit事件。为此,我们在 Django 模板中引入了一点 JavaScript。清单 4-1 展示了这样一个例子。

{% block script %}
   <script>
       const form = document.getElementById("reply-create");
       form.addEventListener('submit', function (event) {

           event.preventDefault();
           const formData = new FormData(this);

           fetch("{% url "support:reply-create" %}", {
               method: 'POST',
               body: formData
           }).then(response => {
               if (!response.ok) throw Error(response.statusText);
               return response;
           }).then(() => {
               location.reload();
               window.scrollTo({top:0});
           });
       });
{% endblock %}

Listing 4-1JavaScript Logic for Form Handling

在这个例子中,我们将 JavaScript 绑定到表单上,这样当用户提交数据时,表单的默认事件就会被拦截和停止。接下来,我们构建一个FormData对象,它被发送给 Django CreateView。还要注意我们如何使用 Django 的url模板标签来构建获取的 URL。为了让这个例子工作,表单必须包含一个CSRF标记,如清单 4-2 所示。

<form id="reply-create">
   {% csrf_token %}
   <!-- fields here -->
</form>

Listing 4-2Django’s CSRF Token Template Tag

如果令牌在表单之外,或者对于任何其他不直接来自表单的POST请求,CSRF令牌必须包含在XHR请求头中。这里概述的例子只是 JavaScript 在 Django 模板中的众多用例之一。正如在第二章 ?? 中简要提到的,我们看到了微框架的寒武纪大爆发,它为 Django 模板增加了足够的交互性。本书篇幅有限,无法涵盖所有可能的例子。在这里,我们将重点放在更广泛的架构上,检查每种方法的优缺点。图 4-1 显示了没有 REST 的伪解耦 Django 的表示。

img/505838_1_En_4_Fig1_HTML.png

图 4-1

一个伪解耦的 Django 项目可以有一个或多个应用,每个应用都有自己的模板。JavaScript 融入到模板中,并与常规 Django 视图对话

记住 Django 在开发速度方面所提供的东西,在伪解耦或混合方法的情况下,我们得到了什么,又失去了什么?

  • 认证和 cookie:因为我们在 Django 模板中提供 JavaScript,所以我们不需要担心复杂的认证方法。我们可以使用内置的会话认证。此外,在伪解耦设置中,cookies 可以在同一个域中的每个请求上自由传播。

  • 表单:Django 有一个惊人的表单系统,在开发过程中节省了大量时间。在伪解耦设置中,我们仍然可以使用 Django 表单来构建用于数据插入的 HTML 结构,只需要足够的 JavaScript 来使它们具有交互性。

  • 什么 JS 库?在伪解耦设置中,我们可以使用任何不需要构建管道的轻量级前端库,比如 Vue.js,或者更好的普通 JavaScript。如果我们事先知道我们的目标是什么用户代理,我们就可以提供现代的 JavaScript 语法,而不需要编译步骤。

  • 路由 : Django 负责路由和 URL 构建。无需担心 JavaScript 路由库或浏览器后退按钮的奇怪问题。

  • 搜索引擎优化:对于内容密集型网站,伪解耦设置往往是最安全的选择,只要我们不使用 JavaScript 动态生成关键内容。

  • 开发人员生产力/负担:在混合设置中,JavaScript 的数量希望很低,以至于我们不需要复杂的构建工具。一切仍然以 Django 为中心,开发人员的认知负荷很低。

  • 测试:在 Django 应用的上下文中测试 JavaScript 交互总是很棘手。Selenium for Python 不支持自动等待。有很多工具,主要是 Selenium 的包装器,比如 Splinter,都有这种能力。然而,在没有支持 JavaScript 的测试运行器的情况下测试伪解耦的 Django 前端仍然很麻烦。像 Cypress 这样的工具,我们将在第九章中介绍,与 Django 配合得非常好,减轻了测试 JavaScript 丰富的接口的负担。

与 REST 伪解耦

不是每个应用都必须被设计成单页应用,从本书开始我们就强调了这一点。

按照唐纳德·克努特的说法,过度设计的应用是万恶之源。然而,也有混合的情况,UI 需要大量的 JavaScript 交互性,而不仅仅是简单的表单处理,但是我们仍然不想离开 Django 的保护伞。在这些配置中,你会发现在 Django 项目中引入像 Vue.js 或 React 这样的 JavaScript 库是合理的。虽然 Vue.js 是高度进步的,但它不想控制所有页面。React 迫使开发人员在 React 中做所有的事情。在这些情况下,由模板构成并增加了表单或模型表单的 Django 前端可能会失去重要性,而支持伪解耦设置,从而:

  • 一个或多个 Django 应用的前端完全由 JavaScript 构建

  • 后端公开了一个 REST API

这种设置与前端与 REST API 位于不同域/源的架构之间的区别在于,在伪解耦设置中,我们在同一个 Django 项目中为 SPA 前端和 REST API 提供服务。这有许多积极的副作用。为什么要在这样的设置中引入 REST?Django CreateView和模型在一定程度上工作良好,之后我们不想重新发明轮子,就像模型的 JSON 序列化一样。Django REST 与现代前端库的结合为健壮的解耦项目打下了坚实的基础。图 4-2 显示了一个带有 REST 的伪解耦 Django 的表示。

img/505838_1_En_4_Fig2_HTML.png

图 4-2

一个带有 REST 的伪解耦 Django 项目可以有一个或多个应用,每个应用都有自己的 REST API。JavaScript 作为 Django 项目中的单页应用,与 Django REST 视图对话

在下一章中,我们将看到一个使用 Django REST 框架和 Vue.js 的伪解耦设置的实际例子。在这里,我们将讨论伪解耦配置的优点和缺点,正如我们在上一节中对 REST-less 设置所做的那样。

  • 认证和 cookie:基于会话的认证是伪解耦项目的默认选择,即使是 REST 也是如此。因为我们在同一个 Django 项目中提供单页面应用,所以在从 JavaScript 发出POST请求之前,只需要通过常规的 Django 视图对用户进行身份验证,并获取适当的 cookies。

  • 表单:如果我们决定构建一个或多个 Django 应用作为单页面应用,我们就失去了使用 Django 表单和模型表单的能力。这开始导致代码重复和团队的更多工作,因为 good 'ol Django 表单及其数据验证层必须用选择的 JavaScript 库重新实现。

  • 什么 JS 库?在 REST 的伪解耦设置中,我们可以使用任何 JavaScript 库或框架。这需要一些额外的步骤来将包包含在 Django 静态系统中,但是这对于任何库都是可能的。

  • 路由:Django 项目中的单页面应用的路由实现起来并不容易。Django 仍然可以为每个应用提供主路径,例如 https://decoupled-django.com/billing/ ,但是每个应用都必须处理其内部路由。与基于历史的路由相比,基于哈希的路由是最简单的路由形式,也最容易实现。

  • 搜索引擎优化:单页面应用(SPAs)不适合内容丰富的网站。在将 SPA 集成到 Django 之前,这是需要考虑的最重要的方面之一。

  • 开发人员的生产力/负担:任何现代的 JavaScript 库都有自己的挑战和工具。在使用 REST 和一个或多个单页面应用的伪解耦设置中,Python 开发人员的开销可能会呈指数级增长。

  • 测试:在使用少量 JavaScript 的伪解耦设置中,考虑到实现自动等待 JavaScript 交互的需要,使用 Selenium 或 Splinter 等工具可能是有意义的。相反,在基于 REST 和 SPA 的伪解耦配置中,以 Python 为中心的工具表现不佳。要测试大量使用 JavaScript 的接口和 JavaScript UI 组件,比如用 Vue.js 或 React 实现的那些,像 Cypress 这样的工具用于功能测试,Jest 用于单元测试是更好的选择。

完全解耦的 Django

与伪解耦设置相反,完全解耦架构,也称为无头,是一种前端和后端完全分离的方法。

在前端,我们可以发现 JavaScript 单页面应用位于与后端不同的域/源上,后端现在充当 REST 或 GraphQL 的数据源。在接下来的两节中,我们将讨论这两种方法。

与 REST 完全解耦

与 REST 完全解耦的 Django 项目是目前最普遍的设置之一。由于其高度的灵活性,REST API 和前端可以部署在不同的域或源上。Django REST 框架是用于在 Django 中构建 REST APIs 的事实上的库,而 JavaScript 以 React、Vue.js 和 Angular 领先于前端。在这些配置中,架构通常安排如下:

  • 一个或多个 Django 应用的前端作为单页 JavaScript 应用存在于 Django 之外

  • 一个或多个 Django 应用公开了一个 REST API

用 REST API 完全解耦配置的 Django 项目可以很好地充当:

  • SPA、移动应用或渐进式 Web 应用的 REST API

  • 静态站点生成工具(SSG)或服务器端呈现的 JavaScript 项目(SSR)的内容存储库

图 4-3 显示了完全解耦的 Django 项目与 REST 的关系。

img/505838_1_En_4_Fig3_HTML.png

图 4-3

带有 REST 的完全解耦的 Django 项目可以有一个或多个应用,每个应用都有自己的 REST API。JavaScript 作为单页应用存在于 Django 项目之外,并通过 JSON 与 Django REST 视图对话

值得注意的是,并不是项目中的每个 Django 应用都必须公开 REST API:人们可以选择分离应用的一个或多个方面,而将其余部分保持在经典的 MVT 安排下。REST 规定的关注点分离为灵活但更复杂的设置开辟了道路。如果我们将 Django 项目与 REST 解耦,会有什么结果呢?

  • 认证和 cookie:完全解耦项目的认证实现起来并不容易。基于会话的认证可以用于 REST 和单页面应用,但是它打破了无状态的限制。有许多不同的方法可以绕过 REST APIs 的基于会话的身份验证的限制,但是在后来的几年中,社区似乎倾向于采用无状态的身份验证机制,比如基于令牌的 JWT 身份验证(JSON web tokens)。然而,由于其安全缺陷和潜在的实施陷阱,JWT 在 Django 社区并不那么受欢迎。

  • 表单:离开 Django 模板和表单意味着我们失去了轻松构建表单的能力。在完全解耦的设置中,表单层通常完全由 JavaScript 构建。数据验证经常在前端重复,现在必须在向后端发送请求之前清理和验证用户输入。

  • 什么 JS 库?在与 REST 完全解耦的设置中,我们可以使用任何 JavaScript 库或框架。将 Django REST 后端与解耦前端配对没有任何特别的限制。

  • 路由:在完全解耦的设置中,Django 不再处理路由。一切都压在客户肩上。对于单页应用,可以选择实现基于哈希的路由或历史路由。

  • 搜索引擎优化:单页应用不太会玩 SEO。然而,随着诸如 Gatsby、Next.js 和 Nuxt.js 等 JavaScript 静态站点生成器的出现,JavaScript 开发人员可以使用最新的闪亮工具从一个无头 Django 项目中生成静态页面,而没有损害 SEO 的风险。

  • 开发人员生产力/负担:在 REST 和一个或多个单页面应用完全解耦的环境中,Python 开发人员的工作量会成倍增加。出于这个原因,大多数 Django 和 Python web 代理都有一个专门的前端团队,专门处理 JavaScript 及其相关工具。

  • 测试:在完全解耦的项目中,前端和后端是分开测试的。APITestCaseAPISimpleTestCase帮助测试 Django REST APIs,而在前端,我们再次看到 Jest 和 Cypress 用于测试 UI。

与 GraphQL 完全解耦

与使用 REST 的完全解耦的 Django 一样,使用 GraphQL 的完全解耦的 Django 项目提供了高度的灵活性,但也带来了更多的技术挑战。

REST 是一项久经考验的技术。另一方面,GraphQL 是最近才出现的,但似乎比 REST 有一些明显的优势。然而,与任何新技术一样,在生产项目中集成新工具和潜在的新挑战之前,开发人员和 CTO 必须仔细评估优缺点。图 4-4 显示了一个用 GraphQL 和 REST API 解耦的 Django 项目。

img/505838_1_En_4_Fig4_HTML.png

图 4-4

完全解耦的 Django 项目可以公开 REST 和 GraphQL APIs。在同一个项目中使用这两种技术并不罕见

在图 4-4 中,我们想象了一个完全解耦的 Django 项目,它公开了两个不同的应用:一个使用 REST,另一个使用 GraphQL。事实上,GraphQL 可以与 REST 共存,以支持从遗留 REST API 到 GraphQL 端点的渐进重构。这对于在从 REST 切换之前评估 GraphQL 或者为 Gatsby 之类的工具公开 GraphQL API 非常有用。拥抱 GraphQL 要付出什么代价?让我想想。

  • 认证和 cookie:在完全解耦的设置中,GraphQL 的认证主要通过基于令牌的认证来处理。在后端,GraphQL 需要实现突变来处理登录、注销、注册和所有相关的极端情况。

  • 什么 JS 库?在 GraphQL 的完全解耦设置中,我们可以使用任何 JavaScript 库或框架。将 Django GraphQL 后端与解耦前端配对没有任何特别的限制。GraphQL 查询甚至可以用Fetch或者XMLHttpRequest来完成。

  • 搜索引擎优化:前端的 GraphQL 多与 React 这样的客户端库配合使用。这意味着我们不能按原样发布客户端生成的页面,否则我们将冒 SEO 受损的风险。Gatsby、Next.js 和 Nuxt.js 等工具可以在 SSG(静态站点生成)模式下运行,从 GraphQL API 生成静态页面。

  • 开发人员生产力/负担 : GraphQL 是一种新颖的技术,尤其是在前端,有十几种方法可以实现数据提取层。GraphQL 似乎提高了开发人员的工作效率,但同时也引入了新的学习内容和新的模式。

由于 GraphQL 是一个数据提取层,所以对表单、路由和测试的考虑与解耦 REST 项目没有什么不同。

摘要

在这一章中,我们概述了分离 Django 项目的各种方法:

  • 带和不带 REST 的伪解耦

  • 与 REST 或 GraphQL 完全解耦

希望你现在已经准备好为你的下一个 Django 项目做出明智的选择。在下一章,我们在转移到 JavaScript 和 Vue.js 之前准备 Django 项目。

五、建立 Django 项目

本章涵盖:

  • 建立 Django 项目

在接下来的部分中,我们开始奠定 Django 项目的结构。

这个项目将伴随我们这本书的剩余部分。它将在第六章中用 REST API 扩展,稍后用 GraphQL API 扩展。

设置项目

首先,为项目创建一个新文件夹,并移入其中:

mkdir decoupled-dj && cd $_

Note

用 Git 将项目置于源代码控制之下是一个好主意。创建项目文件夹后,建议您立即用git init初始化 repo。

进入文件夹后,创建一个新的 Python 虚拟环境:

python3.9 -m venv venv

对于虚拟环境,你可以使用任何高于 3 的 Python 版本;版本越高越好。当环境准备就绪时,激活它:

source venv/bin/activate

要确认虚拟环境是活动的,请在命令提示符下查找(venv)。如果一切就绪,安装 Django:

pip install django

Note

最好安装 Django 3.1 以上的版本,支持异步视图。

接下来,创建您的 Django 项目:

django-admin startproject decoupled_dj .

关于项目文件夹结构的说明:

  • decoupled-dj是回购根

  • decoupled_dj是实际的 Django 项目

  • Django 应用位于decoupled-dj

准备好之后,创建两个 Django 应用。第一款应用命名为billing:

python manage.py startapp billing

第二个应用是博客:

python manage.py startapp blog

这些应用的简要说明。billing将是一个 Django 应用,公开一个用于创建客户发票的 REST API。blog会先公开一个 REST API,再公开一个 GraphQL API。现在检查项目根目录中的所有内容是否都已就位。运行ls -1,您应该会看到如下输出:

blog
billing
decoupled_dj
manage.py
venv

在下一节中,我们将继续项目定制,引入一个定制的 Django 用户。

自定义用户

尽管我们的项目并不严格要求,但是如果您决定将项目投入生产,那么从长远来看,自定义的 Django 用户可以节省您的时间。让我们创建一个。首先,创建一个新的应用:

python manage.py startapp user

打开users/models.py并创建自定义用户,如清单 5-1 所示。

from django.contrib.auth.models import AbstractUser
from django.db.models import CharField

class User(AbstractUser):
   name = CharField(blank=True, max_length=100)

   def __str__(self):
       return self.email

Listing 5-1A Custom Django User

我们保持自定义用户精简和简单,只有一个额外的字段,以允许未来进一步定制。下一步是将AUTH_USER_MODEL添加到我们的设置文件中,但是在此之前,我们需要根据环境来划分我们的设置。

Tip

在费德里科·马拉尼(Federico Marani)的名为《Django 2 和 2 频道的实用书籍(第四章中的“用户模型”一节)中,您将找到 Django 中自定义用户的另一个广泛示例。

插曲:选择正确的数据库

这一步本质上与我们的解耦 Django 项目无关,但是在任何 web 框架中,使用正确的数据库是您可以做的最重要的事情之一。在整本书中,我将使用 Postgres 作为数据库的选择。如果你也想这样做,下面是在你的机器上安装 Postgres 的方法:

  • MacOS 的 Postgres.app

  • 码头下的邮局

  • 通过软件包管理器直接在系统上安装 Postgres

如果您想使用 SQLite,请查看下一节中的说明。

拆分设置文件

在生产环境中部署时,分割设置特别有用,它是根据环境对 Django 设置进行分区的一种方式。在典型的项目中,您可能有:

  • 所有场景通用的基础环境

  • 开发环境,带有开发设置

  • 测试环境,其设置仅适用于测试

  • 登台环境

  • 生产环境

理论是,根据环境的不同,Django 从一个.env文件中加载它的设置。这种方法被称为十二因素应用,由 Heroku 于 2011 年首次推广。在 Django 有很多十二要素的图书馆。一些开发人员更喜欢使用os.environ来完全避免额外的依赖。我最喜欢的图书馆是django-environ。对于我们的项目,我们设置了三个环境:基础、开发和后期生产。让我们安装django-environpsycopg2:

pip install django-environ pyscopg2-binary

(psycopg2只有在使用 Postgres 时才是必需的。)接下来,我们在decoupled_dj.中创建一个名为settings的新 Python 包。在这个文件中,我们导入了django-environ,并放置了 Django 需要运行的所有东西,而不考虑具体的环境。这些设置包括:

  • SECRET_KEY

  • DEBUG

  • INSTALLED_APPS

  • MIDDLEWARE

  • AUTH_USER_MODEL

记住,在上一节中,我们配置了一个定制的 Django 用户。在基本设置中,我们需要在INSTALLED_APPS中包含自定义用户应用,最重要的是,配置AUTH_USER_MODEL。我们的基本设置文件应该类似于清单 5-2 。

import environ
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env()
environ.Env.read_env()
SECRET_KEY = env("SECRET_KEY")
DEBUG = env.bool("DEBUG", False)

INSTALLED_APPS = [
   "django.contrib.admin",
   "django.contrib.auth",
   "django.contrib.contenttypes",
   "django.contrib.sessions",
   "django.contrib.messages",
   "django.contrib.staticfiles",
   "users.apps.UsersConfig",
]
MIDDLEWARE = [ # OMITTED FOR BREVITY ]
ROOT_URLCONF = "decoupled_dj.urls"
TEMPLATES = [ # OMITTED FOR BREVITY ]
WSGI_APPLICATION = "decoupled_dj.wsgi.application
DATABASES = {"default": env.db()
AUTH_PASSWORD_VALIDATORS = [  # OMITTED FOR BREVITY  ]
LANGUAGE_CODE = "en-GB"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = Tru
STATIC_URL = env("STATIC_URL")
AUTH_USER_MODEL = "users.User"

Listing 5-2Base Settings for Our Project

Note

为了简洁起见,我省略了以下配置的完整代码:MIDDLEWARETEMPLATESAUTH_PASSWORD_VALIDATORS。这些应该有来自 stock Django 的默认值。

接下来,我们在decoupled_dj/settings文件夹中创建一个.env文件。根据环境的不同,该文件将具有不同的值。对于开发,我们使用清单 5-3 中的值。

DEBUG=yes
SECRET_KEY=!changethis!
DATABASE_URL=psql://decoupleddjango:localpassword@127.0.0.1/decoupleddjango
STATIC_URL=/static/

Listing 5-3Environment File for Development

如果您想用 SQLite 代替 Postgres,将DATABASE_URL改为:

DATABASE_URL=sqlite:/decoupleddjango.sqlite3

要完成设置,创建一个名为decoupled_dj/settings/development.py的新文件,并从基本设置中导入所有内容。此外,我们还定制配置。这里我们将启用django-extensions,这是 Django 开发中的一个方便的库(清单 5-4 )。

from .base import *  # noqa
INSTALLED_APPS = INSTALLED_APPS + ["django_extensions"]

Listing 5-4decoupled_dj/settings/development.py – The Settings File for Development

让我们也安装库:

pip install django-extensions

我们不要忘记导出DJANGO_SETTINGS_MODULE环境变量:

export DJANGO_SETTINGS_MODULE=decoupled_dj.settings.development

现在,您可以进行迁移了:

python manage.py makemigrations

最后,您可以将它们应用到数据库:

python manage.py migrate

稍后,我们将测试我们的设置。

额外收获:在 ASGI 领导下管理 Django

为了异步运行 Django,我们需要一个 ASGI 服务器。在生产中,您可以将 Uvicorn 与 Gunicorn 一起使用。在开发中,您可能希望单独使用 Uvicorn。安装它:

pip install uvicorn

同样,如果您还没有导出DJANGO_SETTINGS_MODULE环境变量,请不要忘记这样做:

export DJANGO_SETTINGS_MODULE=decoupled_dj.settings.development

接下来,使用以下命令运行服务器:

uvicorn decoupled_dj.asgi:application

如果一切顺利,您应该会看到以下输出:

INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

如果你点击链接,你应该会看到熟悉的 Django 火箭!在继续之前还有一件事:我们需要分割需求文件。

拆分需求文件

正如我们对设置文件所做的那样,将 Django 应用的需求分开是一个很好的实践。我们将在接下来的大部分章节中从事开发工作,现在我们可以将需求分成两个文件:基础和开发。稍后,我们还将为测试和生产添加依赖项。创建一个名为requirements的新文件夹,将base.txtdevelopment.txt文件放入其中。在base文件中,我们放置了项目最重要的依赖项:

  • Django

  • django-environ用于处理.env文件

  • pyscopg2-binary用于连接 Postgres(如果您决定使用 SQLite,则不需要)

  • 在 ASGI 手下管理 Django 的 Uvicorn

您的requirements/base.txt文件应该如下所示:

Django==3.1.3
django-environ==0.4.5
psycopg2-binary==2.8.6
uvicorn==0.12.2

您的requirements/development.txt文件应该如下所示:

-r ./base.txt
django-extensions==3.0.9

Note

当你读到这本书的时候,你的版本很可能和我的不同。

从现在开始,要安装项目的依赖项,您将运行以下命令,其中要求文件将根据您所处的环境而有所不同:

pip install -r requirements/development.txt

Note

这是提交到目前为止所做的更改并将工作推送到 Git repo 的好时机。你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_05_setting_up_project 找到本章的源代码。

摘要

本章准备了 Django 项目,并解释了如何使用异步 ASGI 服务器运行 Django。你学到了:

  • 如何拆分设置和需求

  • 如何在独角兽下经营 django

在下一章,我们终于接触到了 Django 和 JavaScript 前端。

六、使用 Django REST 框架解耦 Django

本章涵盖:

  • 使用 Vue.js 的 Django REST 框架

  • Django 模板中的单页应用

  • 嵌套 DRF 序列化程序

在本章中,您将学习如何使用 Django REST 框架在您的 Django 项目中公开 REST API,以及如何在 Django 中提供单页应用。

构建计费应用

在前一章中,我们在 Django 项目中创建了一个计费应用。如果你还没有这样做,这里有一个快速回顾。首先,配置DJANGO_SETTINGS_MODULE:

export DJANGO_SETTINGS_MODULE=decoupled_dj.settings.development

然后,运行startapp命令来创建应用:

python manage.py startapp billing

如果您喜欢更大的灵活性和更多的输入,您可以将设置文件直接传递给manage.py:

python manage.py command_name --settings=decoupled_dj.settings.development

这里,command_name是您想要运行的命令的名称。您还可以用这个命令创建一个 shell 函数,以避免重复输入。

Note

本章的其余部分假设您在 repo root decoupled-dj中,Python 虚拟环境是活动的。

构建模型

对于这个应用,我们需要两个 Django 模型:Invoice用于实际的发票,ItemLine表示发票中的一行。让我们概括一下这些模型之间的关系:

  • 每个Invoice可以有一个或多个ItemLine

  • 一个ItemLine恰好属于一个Invoice

这是一个多对一(或一对多)关系,这意味着ItemLine将有一个Invoice的外键(如果您需要复习这个主题,请查看本章末尾的参考资料部分)。此外,每个Invoice都与一个User(我们在第三章中构建的自定义 Django 用户)相关联。这意味着:

  • 一个User可以有多个Invoice

  • 每个Invoice属于一个User

为了帮助理解这一点,图 6-1 显示了 ER 图,我们将在其上构建这些 Django 模型。

img/505838_1_En_6_Fig1_HTML.png

图 6-1

计费应用的 ER 图

定义了实体之后,现在让我们构建适当的 Django 模型。打开billing/models.py并如清单 6-1 所示定义模型。

from django.db import models
from django.conf import settings

class Invoice(models.Model):
   class State(models.TextChoices):
       PAID = "PAID"
       UNPAID = "UNPAID"
       CANCELLED = "CANCELLED"

   user = models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
   date = models.DateField()
   due_date = models.DateField()
   state = models.CharField(max_length=15, choices=State.choices, default=State.UNPAID)

class ItemLine(models.Model):
   invoice = models.ForeignKey(to=Invoice, on_delete=models.PROTECT)
   quantity = models.IntegerField()
   description = models.CharField(max_length=500)
   price = models.DecimalField(max_digits=8, decimal_places=2)
   taxed = models.BooleanField()

Listing 6-1billing/models.py – The Models for the Billing App

这里我们利用了 Django 3.0 附带的特性models.TextChoices。至于其他的,它们是标准的 Django 字段,所有的关系都是根据 ER 图建立的。为了增加一点保护,因为我们不想意外删除发票或项目行,我们在它们上面使用了PROTECT

启用应用

当模型准备好时,在decoupled_dj/settings/base.py中启用计费应用,如清单 6-2 所示。

INSTALLED_APPS = [
   ...
   "billing.apps.BillingConfig",
]

Listing 6-2decoupled_dj/settings/base.py - Enabling the Billing App

最后,您可以进行并应用迁移(这是两个独立的命令):

python manage.py makemigrations
python manage.py migrate

有了应用,我们现在准备勾勒界面,然后是后端代码使其工作。

计费应用线框化

在谈论任何前端库之前,让我们先看看我们要构建什么。图 6-2 显示了计费应用的线框,具体来说,就是创建新发票的界面。

img/505838_1_En_6_Fig2_HTML.jpg

图 6-2

计费应用的线框

在编写任何代码之前,记住 UI 是很重要的;这种方法被称为由外向内开发。通过查看接口,我们可以开始考虑我们需要公开什么 API 端点。从前端到后端应该做哪些 HTTP 调用?首先,我们需要获取一个客户端列表来填充表示“选择一个客户端”的select。这是一个对像/billing/api/clients/这样的端点的GET呼叫。至于其他的,一旦我们编译了发票中的所有字段,几乎所有的动态数据都必须通过POST请求发送。这可能是对/billing/api/invoices/的请求。还有一个发送电子邮件按钮,它应该会触发一封电子邮件。概括来说,我们需要进行以下调用:

  • GET来自/billing/api/clients/的所有或部分用户

  • POST新发票的数据到/billing/api/invoices/

  • 用于向客户发送电子邮件(你将在第十一章中学习)

对于任何熟悉 JavaScript 的开发人员来说,这些交互听起来可能微不足道。然而,它们将有助于理解一个典型的解耦项目的架构。在接下来的部分中,我们将 JavaScript 前端与 DRF API 配对。请记住,我们关注的是所有活动部分之间的交互和架构,而不是努力实现完美的代码。

Note

在这个项目中,我们没有专门的客户端模型。客户只是 Django 的用户。为了方便起见,我们在前端使用术语客户端,而对于 Django,每个人都是User

与 Django REST 框架伪解耦

我们在前一章讨论了伪解耦,作为一种用 JavaScript 增强应用前端或者用单页应用完全取代静态前端的方法。

我们还没有深入讨论认证,但是简单地说,伪解耦方法的一个优点是我们可以使用基于会话的内置 Django 认证。在接下来的几节中,我们将实际使用构建交互式前端的最流行的 JavaScript 库之一——vue . js——来看看它是如何融入 Django 的。由于其高度可配置性,Vue.js 是伪解耦 Django 项目的完美匹配。如果你想知道,我们将在本书的后面讨论 React。

vista . js 和 Django

让我们从 Vue 开始。我们希望从 Django 模板中为我们的单页面应用提供服务。

为此,我们必须建立一个 Vue 项目。首先,安装 Vue CLI:

npm install -g @vue/cli

现在我们需要在某个地方创建 Vue 项目。Vue 高度可配置;在大多数情况下,由你来决定把应用放在哪里。为了保持一致性,我们在billing文件夹中创建 Vue 应用,Django 应用已经在这个文件夹中。进入文件夹并运行 Vue CLI:

cd billing
vue create vue_spa

安装程序将询问我们是要手动选择功能还是使用默认预设。对于我们的项目,我们选择以下配置:

  • 视图 2.x

  • 巴比伦式的城市

  • 没有路由

  • Linter/formatter (ESLint 和更漂亮)

  • 专用配置文件中的配置

按 Enter 键,让安装程序配置项目。当包管理器完成所有的依赖项时,花一点时间来探索 Vue 项目结构。一旦完成,您就可以探索在 Django 中使用单页面应用的所有步骤了。

为了使事情易于管理,我们现在只针对开发环境(我们将在下一章讨论生产)。正如第二章所预期的,Django 可以通过集成服务器提供开发中的静态文件。当我们运行python manage.py runserver时,Django 收集所有的静态资产,只要我们配置STATIC_URL。在第三章中,我们拆分了项目的所有设置,我们将STATIC_URL配置为/static/用于开发。开箱即用,Django 可以从每个应用文件夹中收集静态文件,对于我们的计费应用,这意味着我们需要将静态资产放在billing/static中。

有了一堆简单的 JavaScript 文件,这很容易。您只需将它们放在适当的文件夹中。使用像 Vue CLI 或 create-react-app 这样的 CLI 工具,JavaScript 包和所有其他静态资产的目标文件夹已经由工具决定了。对于 Vue CLI 来说,这个文件夹被命名为dist,它应该位于单页应用的同一个项目文件夹中。这对 Django 来说很糟糕,因为它将无法获取这些静态文件。幸运的是,由于 Vue 的可配置性,我们可以将 JavaScript 构建和模板放在 Django 需要的地方。我们可以通过vue.config.js来决定静态文件和index.html应该在哪里结束。由于 Vue CLI 有一个带热重装的集成开发服务器,我们在开发的这个阶段有两个选项:

  • 我们用npm run serve提供应用

  • 我们通过 Django 的开发服务器提供应用

通过第一个选项,我们可以在http://localhost:8081/运行并访问应用,实时查看变化。有了第二种选择,就方便获得更真实的感觉:例如我们可以使用内置的认证系统。为了使用第二个选项,我们需要配置 Vue CLI。

首先,在 Vue 项目文件夹billing/vue_spa中,创建一个名为。env.staging有以下内容:

VUE_APP_STATIC_URL=/static/billing/

注意,这是 Django 的STATIC_URL和 Django 的应用文件夹billing的组合,我们在decoupled_dj/settings/.env中恰当地配置了 ??。接下来,在同一个 Vue 项目文件夹中创建vue.config.js,内容如清单 6-3 所示。

const path = require("path");

module.exports = {
 publicPath: process.env.VUE_APP_STATIC_URL,
 outputDir: path.resolve(__dirname, "../static", "billing"),
 indexPath: path.resolve(__dirname, "../templates/", "billing", "index.html")
};

Listing 6-3billing/vue_spa/vue.config.js – Vue’s Custom Configuration

在这种配置下,我们告诉 Vue:

  • 使用在.env.staging指定的路径作为publicPath

  • 将静态资产放入billing/static/billing内的outputDir

  • index.htmlindexPath放在billing/templates/billing里面

这种设置尊重 Django 关于在哪里找到静态文件和主模板的期望。publicPath是 Vue 应用的预期部署路径。在开发/登台阶段,我们可以指向/static/billing/,Django 将在那里提供文件。在生产中,我们提供了不同的途径。

Note

Django 在静态文件和模板结构方面是高度可配置的。你可以自由地尝试不同的设置。在整本书中,我们将坚持 Django 的股票结构。

现在,您可以在“staging”模式下构建您的 Vue 项目(您应该从 Vue 项目文件夹中运行此命令):

npm run build -- --mode staging

运行构建后,您应该看到 Vue 文件到达预期的文件夹:

  • 静态资产进入billing/static/billing

  • index.html进去billing/templates/billing

为了进行测试,我们需要在 Django 中连接一个视图和 URL。首先,在billing/views.py中创建一个TemplateView的子类来服务 Vue 的index.html,如清单 6-4 所示。

from django.views.generic import TemplateView

class Index(TemplateView):
      template_name = "billing/index.html"

Listing 6-4billing/views.py - Template View for Serving the App Entry Point

Note

如果你更喜欢函数视图,你可以在函数视图中使用render()快捷键来代替TemplateView

接下来,在billing/urls.py中配置主路由,如清单 6-5 所示。

from django.urls import path
from .views import Index

app_name = "billing"

urlpatterns = [
      path("", Index.as_view(), name="index")
]

Listing 6-5billing/urls.py - URL Configuration

最后,在decoupled_dj/urls.py中包含计费应用的 URL,如清单 6-6 所示。

from django.urls import path, include

urlpatterns = [
   path(
       "billing/",
       include("billing.urls", namespace="billing")
   ),
]

Listing 6-6decoupled_dj/urls.py - Project URL Configuration

现在可以在另一个终端中运行 Django development server 了:

python manage.py runserver

如果你访问http://127.0.0.1:8000/billing/,你应该会看到你的 Vue 应用启动并运行,如图 6-3 所示。

img/505838_1_En_6_Fig3_HTML.jpg

图 6-3

我们的 Vue 应用由 Django 的开发服务器提供服务

你可能想知道为什么我们使用术语筹备,而不是开发来进行这个设置。你从这个配置中得到的,实际上,更像是一个“预准备”环境,你可以在 Django 中测试 Vue 应用。这种配置的缺点是,要看到反映的变化,我们每次都需要重新构建 Vue 应用。当然,没有什么能阻止你运行npm run serve来启动带有集成 webpack 服务器的 Vue 应用。在接下来的部分中,我们将完成账单应用的 UI,最后是 REST 后端。

构建 Vue 应用

现在让我们来构建我们的 Vue 应用。首先,清除vue_spa/src/App.vue中的样板文件,从清单 6-7 中显示的代码开始。

<template>
  <div id="app">
      <InvoiceCreate />
  </div>
</template>

<script>
import InvoiceCreate from "@/components/InvoiceCreate";

export default {
  name: "App",
  components: {
      InvoiceCreate
  }
};
</script>

Listing 6-7Main Vue Component

这里我们包括了InvoiceCreate组件。现在,在名为vue_spa/src/components/InvoiceCreate.vue的新文件中创建这个组件(您也可以删除HelloWorld.vue)。清单 6-8 首先显示模板部分。

<template>
 <div class="container">
   <h2>Create a new invoice</h2>
   <form @submit.prevent="handleSubmit">
     <div class="form">
       <div class="form__aside">
         <div class="form__field">
           <label for="user">Select a client</label>
           <select id="user" name="user" required>
             <option value="--">--</option>
             <option v-for="user in users" :key="user.email" :value="user.id">
               {{ user.name }} - {{ user.email }}
             </option>
           </select>
         </div>
         <div class="form__field">
           <label for="date">Date</label>
           <input id="date" name="date" type="date" required />
         </div>
         <div class="form__field">
           <label for="due_date">Due date</label>
           <input id="due_date" name="due_date" type="date" required />
         </div>
       </div>
       <div class="form__main">
         <div class="form__field">
           <label for="quantity">Qty</label>
           <input
             id="quantity"
             name="quantity"
             type="number"
             min="0"
             max="10"
             required
           />
         </div>
         <div class="form__field">
           <label for="description">Description</label>
           <input id="description" name="description" type="text" required />
         </div>
         <div class="form__field">
           <label for="price">Price</label>
           <input
             id="price"
             name="price"
             type="number"
             min="0"
             step="0.01"
             required
           />
         </div>
         <div class="form__field">
           <label for="taxed">Taxed</label>
           <input id="taxed" name="taxed" type="checkbox" />
         </div>
       </div>
     </div>
     <div class="form__buttons">
       <button type="submit">Create invoice</button>
       <button disabled>Send email</button>
     </div>
   </form>
 </div>
</template>

Listing 6-8Template Section of the Vue Form Component

在这个标记中,我们有:

  • 用于选择客户的select

  • 两个日期输入

  • 数量、描述和价格的输入

  • 已征税的复选框

  • 两个按钮

接下来是逻辑部分,带有典型的表单处理,如清单 6-9 所示。

<script>
export default {
 name: "InvoiceCreate",
 data: function() {
   return {
     users: [
       { id: 1, name: "xadrg", email: "xadrg@acme.io" },
       { id: 2, name: "olcmf", email: "olcmf@zyx.dev" }
     ]
   };
 },
 methods: {
   handleSubmit: function(event) {
     // eslint-disable-next-line no-unused-vars
     const formData = new FormData(event.target);

     // TODO - build the request body
     const data = {};

     fetch("/billing/api/invoices/", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify(data)
     })
       .then(response => {
         if (!response.ok) throw Error(response.statusText);
         return response.json();
       })
       .then(json => {
         console.log(json);
       })
       .catch(err => console.log(err));
   }
 },
 mounted() {
   fetch("/billing/api/clients/")
     .then(response => {
       if (!response.ok) throw Error(response.statusText);
       return response.json();
     })
     .then(json => {
       this.users = json;
     });
 }
};
</script>

Listing 6-9JavaScript Section of the Vue Form Component

在这段代码中,我们有:

  • Vue 组件状态中的一个users属性

  • 一种处理表单提交的方法

  • 一种在装载时获取数据的mounted生命周期方法

此外,我们以 API 端点(尚未实现)为目标:/billing/api/clients//billing/api/invoices/。你可以在users里注意到一些假数据;这是为了在我们等待构建 REST API 时,我们有一个最小的可用接口。

Tip

可以不用后端开发前端,用 Mirage JS 之类的工具,可以拦截和响应 HTTP 调用。

为了让代码工作,记得在vue_spa/src/components/InvoiceCreate.vue中把模板和脚本部分按顺序放好。有了这个最小的实现,您现在就可以用两种方式开始这个项目了。要构建应用并使用 Django 提供它,请在 Vue 项目文件夹中运行以下命令:

npm run build -- --mode staging

然后,启动 Django 开发服务器,前往http://localhost:8000/billing/。要使用其开发服务器运行 Vue,请在 Vue 文件夹中运行以下命令:

npm run serve

该应用将在http://localhost:8081/开始,但由于我们还没有后端,没有什么将为最终用户工作。同时,我们可以设置应用,以便:

  • 当在 Django 的保护伞下启动时,它调用/billing/api/clients//billing/api/invoices/

  • 当使用集成的 webpack 服务器调用时,它调用http://localhost:8000/billing/api/clients/http://localhost:8000/billing/api/invoices/,这是 DRF 将监听的端点

为此,打开vue.config.js并在配置中添加清单 6-10 中的行。

// omitted
module.exports = {
  // omitted
  devServer: {
      proxy: "http://localhost:8000"
  }
};

Listing 6-10Development Server Configuration for Vue CLI

这确保了该项目在使用伪解耦设置的试运行/生产中以及作为独立应用的开发中运行良好。很快,我们将最终构建 REST 后端。

vue . js、Django 和 CSS

在这一点上,你可能想知道 CSS 在大局中的位置。我们的 Vue 组件确实有一些类,但是在前面的部分中我们没有显示任何 CSS 管道。

原因是在我们正在构建的项目中,至少有两种使用 CSS 的方法。具体来说,您可以:

  • 在基本 Django 模板中包含 CSS

  • 包括来自每个单页应用的 CSS

在撰写本文时,Tailwind 是 Django 场景中最流行的 CSS 库之一。在伪解耦设置中,您可以在主 Django 项目中配置 Tailwind,在基本模板中包含 CSS 包,并让单页 Vue 应用扩展基本模板。如果每个单页应用都是独立的,每个都有自己的风格,你可以单独配置 Tailwind 和 friends。请注意,从长远来看,第二种方法的可维护性可能有点困难。

Note

你可以在本章的源代码 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_06_decoupled_with_drf 中找到组件的最小 CSS 实现。

构建 REST 后端

我们在 Vue 组件中留了一个说明,写着// TODO - build the request body。这是因为使用我们构建的表单,我们不能将请求原样发送到 Django REST 框架。你马上就会明白原因。同时,有了 UI,我们可以将后端与 DRF 连接起来。基于我们从 UI 调用的端点,我们需要公开以下来源:

  • /billing/api/clients/

  • /billing/api/invoices/

让我们回顾一下所有实体之间的关系:

  • 每个Invoice可以有一个或多个ItemLine

  • 一个ItemLine恰好属于一个Invoice

  • 一个User可以有多个Invoice

  • 每个Invoice属于一个User

这是什么意思?当过帐到后端以创建新发票时,Django 希望:

  • 与发票相关联的用户 ID

  • 与发票关联的一个或多个物料行

用户不是问题,因为我们从对/billing/api/clients/的第一次 API 调用中就抓住了它。每个项目和相关的发票不能作为一个整体从前端发送。我们需要:

  • 在前端构建正确的对象

  • 调整 DRF 中的 ORM 逻辑以保存相关对象

构建序列化程序

首先,我们需要在 DRF 中创建以下组件:

  • 用于User的串行器

  • 用于Invoice的串行器

  • 用于ItemLine的串行器

首先,让我们安装 Django REST 框架:

pip install djangorestframework

安装完成后,更新requirements/base.txt以包含 DRF:

Django==3.1.3
django-environ==0.4.5
psycopg2-binary==2.8.6
uvicorn==0.12.2
djangorestframework==3.12.2

接下来,启用decoupled_dj/settings/base.py中的 DRF,如清单 6-11 所示。

INSTALLED_APPS = [
   ...
   "users.apps.UsersConfig",
   "billing.apps.BillingConfig",
   "rest_framework", # enables DRF
]

Listing 6-11decoupled_dj/settings/base.py - Django Installed Apps with the DRF Enabled

现在在billing中创建一个名为api的新 Python 包,这样我们就有了一个billing/api文件夹。在这个包中,我们放置了 REST API 的所有逻辑。现在让我们构建序列化程序。创建一个名为billing/api/serializers.py的新文件,内容如清单 6-12 所示。

from users.models import User
from billing.models import Invoice, ItemLine
from rest_framework import serializers

class UserSerializer(serializers.ModelSerializer):
   class Meta:
       model = User
       fields = ["id", "name", "email"]

class ItemLineSerializer(serializers.ModelSerializer):
   class Meta:
       model = ItemLine
       fields = ["quantity", "description", "price", "taxed"]

class InvoiceSerializer(serializers.ModelSerializer):
   items = ItemLineSerializer(many=True, read_only=True)

   class Meta:
       model = Invoice
       fields = ["user", "date", "due_date", "items"]

Listing 6-12billing/api/serializers.py – The DRF Serializers

这里我们有三个串行化器。UserSerializer会连载我们的User型号。ItemLineSerializerItemLine的序列化器。最后,InvoiceSerializer将连载我们的Invoice车型。每个串行化器都子类化 DRF 的ModelSerializer,我们在第三章中遇到过,并且有适当的字段映射到相应的模型。列表中的最后一个序列化器InvoiceSerializer很有趣,因为它包含一个嵌套的ItemLineSerializer。正是这个序列化程序需要一些工作来符合我们的前端。要了解原因,让我们构建视图。

构建视图和 URL

创建一个名为billing/api/views .py的新文件,代码如清单 6-13 所示。

from .serializers import InvoiceSerializer, UserSerializer, User
from rest_framework.generics import CreateAPIView, ListAPIView

class ClientList(ListAPIView):
   serializer_class = UserSerializer
   queryset = User.objects.all()

class InvoiceCreate(CreateAPIView):
   serializer_class = InvoiceSerializer

Listing 6-13billing/api/views.py - DRF Views

这些视图将分别响应/billing/api/clients//billing/api/invoices/。在这里,ClientList是通用 DRF 列表视图的子类。InvoiceCreate改为子类化 DRF 的通用创建视图。我们现在准备好为我们的应用连接 URL。打开billing/urls.py并定义您的路线,如清单 6-14 所示。

from django.urls import path
from .views import Index
from .api.views import ClientList, InvoiceCreate

app_name = "billing"

urlpatterns = [
   path("", Index.as_view(), name="index"),
   path(
       "api/clients/",
       ClientList.as_view(),
       name="client-list"),
   path(
       "api/invoices/",
       InvoiceCreate.as_view(),
       name="invoice-create"),
]

Listing 6-14billing/urls.py - URL Patterns for the Billing API

这里,app_name与主项目 URL 中的名称空间配对将允许我们用reverse()调用billing:client-listbilling:invoice-create,这在测试中特别有用。最后一步,您应该在decoupled_dj/urls.py中配置好 URL,如清单 6-15 所示。

from django.urls import path, include

urlpatterns = [
   path(
       "billing/",
       include("billing.urls", namespace="billing")
   ),
]

Listing 6-15decoupled_dj/urls.py - The Main Project URL Configuration

我们已经做好了测试的准备。为了在数据库中创建几个模型,您可以启动一个增强的 shell(这来自于django-extensions):

python manage.py shell_plus

要创建模型,运行以下查询(>>>是 shell 提示符):

>>> User.objects.create_user(username="jul81", name="Juliana", email="juliana@acme.io")
>>> User.objects.create_user(username="john89", name="John", email="john@zyx.dev")

退出 shell 并启动 Django:

python manage.py runserver

在另一个终端中,运行下面的curl命令,看看会发生什么:

curl -X POST --location "http://127.0.0.1:8000/billing/api/invoices/" \
   -H "Accept: */*" \
   -H "Content-Type: application/json" \
   -d "{
         \"user\": 1,
         \"date\": \"2020-12-01\",
         \"due_date\": \"2020-12-30\"
       }"

作为响应,您应该会看到以下输出:

{"user":1,"date":"2020-12-01","due_date":"2020-12-30"}

这是 Django REST 框架告诉我们它在数据库中创建了一个新的发票。目前为止一切顺利。现在在发票上加一些项目怎么样?为此,我们需要使序列化程序可写。在billing/api/serializers.py中,将read_only=Trueitems字段中移除,使其看起来像列表 6-16 。

class InvoiceSerializer(serializers.ModelSerializer):
   items = ItemLineSerializer(many=True)

   class Meta:
       model = Invoice
       fields = ["user", "date", "due_date", "items"]

Listing 6-16billing/api/serializers.py - The Serializer for an Invoice, Now with a Writable Relationship

您可以使用 curl 再次进行测试,这一次也传递两个项目:

curl -X POST --location "http://127.0.0.1:8000/billing/api/invoices/" \
   -H "Accept: application/json" \
   -H "Content-Type: application/json" \
   -d "{
         \"user\": 1,
         \"date\": \"2020-12-01\",
         \"due_date\": \"2020-12-30\",
         \"items\": [
           {
             \"quantity\": 2,
             \"description\": \"JS consulting\",
             \"price\": 9800.00,
             \"taxed\": false
           },
           {
             \"quantity\": 1,
             \"description\": \"Backend consulting\",
             \"price\": 12000.00,
             \"taxed\": true
           }
         ]
       }"

此时,所有东西都应该爆炸,您应该会看到以下异常:

TypeError: Invoice() got an unexpected keyword argument 'items'
Exception Value: Got a TypeError when calling Invoice.objects.create().

这可能是因为您在 serializer 类上有一个可写字段,它不是Invoice.objects.create()的有效参数。您可能需要将该字段设为只读,或者覆盖InvoiceSerializer.create()方法来正确处理这个问题。

Django REST 要求我们调整InvoiceSerializer中的create(),以便它可以接受发票旁边的项目。

使用嵌套序列化程序

打开billing/api/serializers.py并修改串行器,如清单 6-17 所示。

class InvoiceSerializer(serializers.ModelSerializer):
   items = ItemLineSerializer(many=True)

   class Meta:
       model = Invoice
       fields = ["user", "date", "due_date", "items"]

   def create(self, validated_data):
       items = validated_data.pop("items")
       invoice = Invoice.objects.create(**validated_data)
       for item in items:
           ItemLine.objects.create(invoice=invoice, **item)
       return invoice

Listing 6-17billing/api/serializers.py - The Serializer for an Invoice, Now with a Customized create()

这也是调整ItemLine模型的好时机。正如您从序列化程序中看到的,我们正在使用items字段来设置给定发票上的相关项目。问题是,Invoice模型中没有这样的字段。这是因为 Django 模型上的反向关系可以作为modelname _set访问,除非有不同的配置。要修复该字段,打开billing/models.py并将related_name属性添加到发票行,如清单 6-18 所示。

class ItemLine(models.Model):
   invoice = models.ForeignKey(
     to=Invoice, on_delete=models.PROTECT, related_name="items"
   )
   ...

Listing 6-18billing/models.py - The ItemLine Model with a related_name

保存文件后,按如下方式运行迁移:

python manage.py makemigrations billing
python manage.py migrate

启动 Django 后,您现在应该能够重复相同的curl请求,这次成功了。在这个阶段,我们也可以修复前端。

固定 Vue 前端

vue_spa/src/components/InvoiceCreate.vue中,找到显示// TODO - build the request body的行,并调整代码,如清单 6-19 所示。

 methods: {
   handleSubmit: function(event) {
     const formData = new FormData(event.target);

     const data = Object.fromEntries(formData);
     data.items = [
       {
         quantity: formData.get("quantity"),
         description: formData.get("description"),
         price: formData.get("price"),
         taxed: Boolean(formData.get("taxed"))
       }
     ];

     fetch("/billing/api/invoices/", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify(data)
     })
       // omitted;
   }
 }

Listing 6-19The handleSubmit Method from the Vue Component

为了简洁起见,我只展示了相关的部分。这里,我们使用Object.fromEntries() (ECMAScript 2019)从我们的表单构建一个对象。然后,我们继续向对象添加一个项目数组(目前只有一个项目)。我们最后发送对象作为fetch的主体负载。您可以使用集成服务器运行 Vue(从 Vue 项目文件夹中):

npm run serve

您应该看到一个在http://localhost:8080/创建发票的表单。尝试填写表格并点击创建发票。在浏览器控制台中,您应该看到来自 Django REST 框架的响应,发票被成功保存到数据库中。干得好!我们完成了这个解耦的 Django 项目的第一个真正的特性。

Note

这是提交到目前为止所做的更改并将工作推送到 Git repo 的好时机。你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_06_decoupled_with_drf 找到本章的源代码。

Exercise 6-1: Handling Multiple Items

扩展 Vue 组件来处理发票的多个项目。用户应该能够单击加号(+)按钮向表单添加更多项目,这些项目应该与请求一起发送。

摘要

本章将 Vue.js 前端与 Django REST 框架 API 配对,Vue.js 在与主 Django 项目相同的上下文中提供服务。

通过这样做,您学会了如何:

  • 将 Vue.js 集成到 Django 中

  • 通过 JavaScript 与 DRF API 进行交互

  • 在 Django REST 框架中使用嵌套的序列化程序

在下一章中,我们将探讨一个更真实的场景。在再次转向 JavaScript 领域之前,我们将在第八章的 Next.js 中讨论安全性和部署。

额外资源

七、API 安全性和部署

本章涵盖:

  • Django 硬化

  • REST API 强化

  • 部署到生产

在前一章中,我们用 Django REST 框架和 Vue.js 组装了一个伪解耦的 Django 项目。

现在是时候探索这种设置的安全含义了,这与运行 monolith 没有什么不同,但是由于 REST API 的存在,确实需要一些额外的步骤。在关注安全性之后,在本章的第二部分,我们将介绍使用 Gunicorn 和 NGINX 部署到生产环境中。

Note

在本章的第一部分,我们假设您在回购根decoupled-dj中,Python 虚拟环境是活动的,并且DJANGO_SETTINGS_MODULE被配置为decoupled_dj.settings.development

Django 硬化

Django 是最安全的 web 框架之一。

然而,很容易让事情溜走,尤其是当我们急于看到我们的项目在生产中启动和运行的时候。在向世界公开我们的网站或 API 之前,我们需要注意一些额外的细节以避免意外。重要的是要记住,本章提供的建议远非详尽无遗。安全性是一个巨大的话题,由于地区法规或政府要求,每个项目和每个团队在安全性方面可能都有不同的需求。

Django 生产设置

在第五章的“分割设置文件”一节中,我们配置了 Django 项目,为每个环境使用不同的设置。

到目前为止,我们有以下设置:

  • decoupled_dj/settings/base.py

  • decoupled_dj/settings/development.py

为了准备项目的生产,我们在decoupled_dj/settings/production.py中创建另一个设置文件,它将保存所有与生产相关的设置。这个文件里应该放些什么?Django 最重要的生产环境包括:

  • SECURE_SSL_REDIRECT:确保每个通过 HTTP 的请求都被重定向到 HTTPS

  • 驱动 Django 将服务的主机名

  • Django 将在这里寻找静态文件

除了这些设置之外,还有一些与 DRF 相关的配置,我们将在下一节中讨论。我们还会在第十章中介绍更多与认证相关的设置。首先,创建decoupled_dj/settings/production.py并如清单 7-1 所示进行配置。

from .base import *  # noqa

SECURE_SSL_REDIRECT = True
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
STATIC_ROOT = env("STATIC_ROOT")

Listing 7-1decoupled_dj/settings/production.py – The First Settings for Production

这些设置将根据环境从.env文件中读取。在开发中,我们有清单 7-2 中所示的设置。

DEBUG=yes
SECRET_KEY=!changethis!
DATABASE_URL=psql://decoupleddjango:localpassword@127.0.0.1/decoupleddjango
STATIC_URL=/static/

Listing 7-2The Development .env File

Note

如果我们传递的是yes而不是一个布尔值,那么DEBUG在这里是如何工作的?转换由django-environ为我们处理。

在生产中,我们需要根据我们在decoupled_dj/settings/production.py中描述的需求来调整这个文件。这意味着我们必须部署清单 7-3 中所示的.env文件。

ALLOWED_HOSTS=decoupled-django.com,static.decoupled-django.com
DEBUG=no
SECRET_KEY=!changethis!
DATABASE_URL=psql://decoupleddjango:localpassword@127.0.0.1/decoupleddjango
STATIC_URL=https://static.decoupled-django.com
STATIC_ROOT=static/

Listing 7-3decoupled_dj/settings/.env.production.example - The Production .env File

Note

这里显示的数据库设置假设我们使用 Postgres 作为项目的数据库。要使用 SQLite,请将数据库配置更改为DATABASE_URL=sqlite:/decoupleddjango.sqlite3

生产中最重要的是禁用DEBUG以避免错误泄漏。在前面的文件中,请注意静态相关设置与开发略有不同:

  • STATIC_URL现在被配置为从static.decoupled-django.com子域读取静态资产

  • 生产中的STATIC_ROOT将从static文件夹中读取文件

有了这个用于生产的基本配置,我们可以进一步加强我们的 Django 项目,使用身份验证。

Django 中的身份验证和 Cookies

在前一章中,我们配置了一个 Vue.js 单页应用,从 Django 视图提供服务。让我们回顾一下billing/views.py中的代码,清单 7-4 中总结了这些代码。

from django.views.generic import TemplateView

class Index(TemplateView):
   template_name = "billing/index.html"

Listing 7-4billing/views.py - A TemplateView Serves the Vue.js SPA

在本地,我们可以在运行 Django 开发服务器后在http://127.0.0.1:8000/billing/访问视图,这很好。然而,一旦项目上线,没有什么可以阻止匿名用户自由地访问视图和发出未经验证的请求。为了强化我们的项目,我们可以首先使用基于类的视图的LoginRequiredMixin来要求对视图进行认证。打开billing/views.py并改变视图,如清单 7-5 所示。

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView

class Index(LoginRequiredMixin, TemplateView):
   template_name = "billing/index.html"

Listing 7-5billing/views.py - Adding Authentication to the Billing View

从现在开始,任何想要访问该视图的用户都必须进行身份验证。对于现阶段的我们来说,使用以下命令在开发中创建一个超级用户就足够了:

python manage.py createsuperuser

完成后,我们可以通过管理视图进行身份验证,然后访问 http://127.0.0.1:8000/billing/ 来创建新发票。但是一旦我们填写表单并点击 Create Invoice,Django 就会返回一个错误。在浏览器控制台的 Network 选项卡中,在尝试提交表单后,我们应该在服务器的响应中看到以下错误:

"CSRF Failed: CSRF token missing or incorrect."

Django 可以抵御 CSRF 攻击,如果没有有效的 CSRF 令牌,它不会让我们提交 AJAX 请求。在传统的 Django 表单中,这个令牌通常作为一个模板标签包含在内,并由浏览器作为 cookie 发送到后端。但是,当前端完全由 JavaScript 构建时,必须从 cookie 存储中检索 CSRF 令牌,并作为报头与请求一起发送。为了在我们的 Vue.js 应用中解决这个问题,我们可以使用vue-cookies,这是一个用于处理 cookies 的方便的库。在终端中,移动到名为billing/vue_spa的 Vue 项目文件夹并运行以下命令:

npm i vue-cookies

接下来,在billing/vue_spa/src/main.js中加载库,如清单 7-6 所示。

...
import VueCookies from "vue-cookies";

Vue.use(VueCookies);
...

Listing 7-6billing/vue_spa/src/main.js - Enabling Vue-Cookies

最后,在billing/vue_spa/src/components/InvoiceCreate.vue中,获取 cookie 并将其包含为一个头,如清单 7-7 所示。

...
     const csrfToken = this.$cookies.get("csrftoken");

     fetch("/billing/api/invoices/", {
       method: "POST",
       headers: {
         "Content-Type": "application/json",
         "X-CSRFToken": csrfToken
       },
       body: JSON.stringify(data)
     })
       .then(response => {
         if (!response.ok) throw Error(response.statusText);
         return response.json();
       })
       .then(json => {
         console.log(json);
       })
       .catch(err => console.log(err));
...

Listing 7-7billing/vue_spa/src/components/InvoiceCreate.vue - Including the CSRF Token in the AJAX Request

为了进行测试,我们可以使用以下命令重新构建 Vue 应用:

npm run build -- --mode staging

在运行 Django 之后,在http://127.0.0.1:8000/billing/创建一个新的发票应该可以正常工作了。

Note

作为 Fetch 的一个流行替代,axios 可以帮助实现拦截器特性。对于每个请求,全局附加 cookies 或其他头是很方便的。

回到认证的前面。在这个阶段,我们在 Django 中启用了最简单的认证方法:基于会话的认证。这是 Django 中最传统和最健壮的认证机制之一。它依赖于保存在 Django 数据库中的会话。当用户使用凭证登录时,Django 在数据库中存储一个会话,并向用户的浏览器发回两个 cookie:csrftokensessionid。当用户向网站发出请求时,浏览器发回这些 cookiess,Django 根据数据库中存储的内容对这些 cookie 进行验证。由于如今 HTTPS 加密是网站的强制性要求,禁用通过普通 HTTP 传输csrftokensessionid是有意义的。为此,我们可以在decoupled_dj/settings/production.py中添加两个配置指令,如清单 7-8 所示。

...
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
...

Listing 7-8decoupled_dj/settings/production.py - Securing Authentication Cookies

通过将CSRF_COOKIE_SECURESESSION_COOKIE_SECURE设置为True,我们确保与会话认证相关的 cookies 仅通过 HTTPS 传输。

随机化管理 URL

内置的管理面板可能是 Django 最受欢迎的特性之一。然而,该面板的 URL(默认为admin/)在网站在线暴露时,可能会成为自动暴力攻击的目标。为了缓解这个问题,我们可以在 URL 中引入一点随机性,将它改为不容易猜到的内容。这个变更需要发生在项目根decoupled_dj/urls.py中,如清单 7-9 所示。

from django.urls import path, include
from django.contrib import admin
from django.conf import settings

urlpatterns = [
   path("billing/", include("billing.urls", namespace="billing")),
]

if settings.DEBUG:
   urlpatterns = [
       path("admin/", admin.site.urls),
   ] + urlpatterns

if not settings.DEBUG:
   urlpatterns = [
       path("77randomAdmin@33/", admin.site.urls),
   ] + urlpatterns

Listing 7-9decoupled_dj/urls.py - Hiding the Real Admin URL in Production

这段代码告诉 Django,当DEBUGFalse时,将管理 URL 从admin/更改为77randomAdmin@33/。通过这个小小的改变,我们给管理面板增加了更多的保护。现在让我们看看我们可以做些什么来提高 REST API 的安全性。

REST API 强化

什么比 REST API 更好?当然是安全的 REST API。

在接下来的几节中,我们将介绍一组提高 REST API 安全性的策略。为此,我们从 OWASP 基金会的 REST 安全备忘单中借鉴了一些指导。

HTTPS 加密和 HSTS

如今,HTTPS 是每个网站的必争之地。

通过在 Django 项目中配置SECURE_SSL_REDIRECT,我们确保了 REST API 也是安全的。当我们在下一节讨论部署时,我们将看到在我们的设置中,NGINX 为我们的 Django 项目提供了 SSL 终端。除了 HTTPS,我们还可以配置 Django 将名为Strict-Transport-Security的 HTTP 头附加到响应上。这样做,我们可以确保浏览器只能通过 HTTPS 连接到我们的网站。这个特性被称为 HSTS,虽然 Django 有与 HSTS 相关的设置,但通常的做法是在 web 服务器/代理级别添加这些头。网站 https://securityheaders.com 提供了一个免费的扫描器,可以帮助识别哪些安全头可以添加到 NGINX 配置中。

审核日志记录

审计日志是指为系统中的每个操作写日志的实践——无论是 web 应用、REST API 还是数据库——作为记录特定时间点“谁做了什么”的一种方式。

与日志聚合系统配合使用,审计日志记录是提高数据安全性的好方法。OWASP REST 安全备忘单规定了 REST APIs 的审计日志。Django 已经在 admin 中提供了一些最小形式的审计日志。另外,Django 中的用户表记录了系统中每个用户的最后一次登录。但是这两种方法远不是一个成熟的审计日志解决方案,也没有涵盖 REST API。Django 有几个包可以添加审计日志功能:

  • django-simple-history

  • django-auditlog

django-simple-history可以跟踪模型的变化。这种能力与访问日志相结合,可以为 Django 项目提供有效的审计日志。django-simple-history是一个成熟的包,积极支持。另一方面,django-auditlog提供了相同的功能,但在撰写本文时它仍在开发中。

跨产地资源共享

在解耦设置中,JavaScript 是 REST 和 GraphQL APIs 的主要消费者。

默认情况下,JavaScript 可以用XMLHttpRequestfetch请求资源,只要服务器和前端住在同一个原点。HTTP 中的源是方案或协议、域和端口的组合。这意味着原点http://localhost:8000不等于http://localhost:3000。当 JavaScript 试图从不同的来源获取资源时,浏览器中就会出现一种称为跨来源资源共享 (CORS)的机制。在任何 REST 或 GraphQL 项目中,CORS 都是控制哪些源可以连接到 API 所必需的。为了在 Django 中启用 CORS,我们可以用下面的命令在我们的项目中安装django-cors-headers:

pip install django-cors-headers

要启用该包,在decoupled_dj/settings/base.py中包含corsheaders,如清单 7-10 所示。

INSTALLED_APPS = [
   ...
   'corsheaders',
   ...
]

Listing 7-10decoupled_dj/settings/base.py - Enabling django-cors-headers in Django

接下来,在中间件列表中更高的位置启用 CORS 中间件,如清单 7-11 所示。

MIDDLEWARE = [
   ...
   'corsheaders.middleware.CorsMiddleware',
   'django.middleware.common.CommonMiddleware',
   ...
]

Listing 7-11decoupled_dj/settings/base.py - Enabling CORS Middleware

有了这个改变,我们就可以配置django-cors-headers。在发展中,我们可能希望让所有的起源完全绕过 CORS。向decoupled_dj/settings/development.py添加清单 7-12 中所示的配置。

CORS_ALLOW_ALL_ORIGINS = True

Listing 7-12Decoupled_dj/settings/development.py - Relaxing CORS in Development

在生产中,我们必须更加严格。django-cors-headers允许我们定义一个允许原点的列表,可以在decoupled_dj/settings/production.py中配置,如清单 7-13 所示。

CORS_ALLOWED_ORIGINS = [
   "https://example.com",
   "http://another1.io",
   "http://another2.io",
]

Listing 7-13decoupled_dj/settings/production.py - Hardening CORS in Production

因为我们在每个环境中使用变量,所以我们可以把这个配置指令做成一个列表,如清单 7-14 所示。

CORS_ALLOWED_ORIGINS = env.list(
   "CORS_ALLOWED_ORIGINS",
   default=[]
)

Listing 7-14decoupled_dj/settings/production.py - Hardening CORS in Production

这样,我们可以在.env中将允许的原点定义为一个逗号分隔的列表,用于生产。CORS 是保护用户的一种基本形式,因为如果没有这种机制,任何网站都可以在页面中获取和注入恶意代码,它也是对 REST APIss 的一种保护,REST API 可以明确允许预定义来源的列表,而不是向外界开放。当然,CORS 并不能完全取代认证,认证将在下一节中简要介绍。

DRF 的认证和授权

DRF 的身份验证与 Django 已经提供的现成功能无缝集成。默认情况下,DRF 使用两个类别对用户进行身份验证,SessionAuthenticationBasicAuthentication,这两个类别是根据网站最常用的两种身份验证方法恰当命名的。基本身份验证是一种非常不安全的身份验证方法,即使在 HTTPS 下也是如此,因此完全禁用它,至少只启用基于会话的身份验证是有意义的。要配置 DRF 的这一方面,打开decoupled_dj/settings/base.py,添加REST_FRAMEWORK字典,并配置所需的认证类,如清单 7-15 所示。

REST_FRAMEWORK = {
   "DEFAULT_AUTHENTICATION_CLASSES": [
       "rest_framework.authentication.SessionAuthentication",
   ],
}

Listing 7-15decoupled_dj/settings/base.py - Tweaking Authentication for the Django REST Framework

在 web 应用中,身份验证指的是“你是谁?”身份识别流程的一部分。相反,授权查看“您可以用您的凭证做什么”部分。事实上,单靠身份验证不足以保护网站或 REST API 中的资源。到目前为止,我们的计费应用的 REST API 对任何用户开放。具体来说,我们需要在billing/api/views.py中获得两个 DRF 视图,在清单 7-16 中进行了总结。

from .serializers import InvoiceSerializer
from .serializers import UserSerializer, User
from rest_framework.generics import CreateAPIView, ListAPIView

class ClientList(ListAPIView):
   serializer_class = UserSerializer
   queryset = User.objects.all()

class InvoiceCreate(CreateAPIView):
   serializer_class = InvoiceSerializer

Listing 7-16billing/api/views.py – The DRF View for the Billing App

这两个视图处理以下端点的逻辑:

  • /billing/api/clients/

  • /billing/api/invoices/

现在,任何人都可以访问这两个网站。默认情况下,DRF 不对视图强制任何形式的权限。默认的权限类是AllowAny。为了修复项目中所有 DRF 视图的安全性,我们可以全局应用IsAdminUser权限。为此,在decoupled_dj/settings/base.py中,我们用一个许可类来扩充REST_FRAMEWORK字典,如清单 7-17 所示。

REST_FRAMEWORK = {
   "DEFAULT_AUTHENTICATION_CLASSES": [
       "rest_framework.authentication.SessionAuthentication",
   ],
   "DEFAULT_PERMISSION_CLASSES": [
       "rest_framework.permissions.IsAdminUser"
   ],
}

Listing 7-17decoupled_dj/setting/base.py - Adding Permissions Globally in the DRF

权限类不仅可以全局设置,还可以在单个视图上设置,这取决于特定的用例。

Note

我们也可以只在decoupled_dj/settings/production.py执行这些检查。这意味着我们不会被开发中的认证所困扰。然而,我更喜欢全局应用身份验证和授权,以确保更真实的场景,尤其是在测试中。

禁用可浏览 API

DRF 简化了构建 REST APIs 的大部分日常工作。当我们创建一个端点时,DRF 为我们提供了一个与 API 交互的免费 web 接口。例如,对于创建视图,我们可以通过界面访问 HTML 表单来创建新对象。在这方面,可浏览 API 对开发者来说是一个巨大的福音,因为它提供了一个方便的 UI 来与 API 交互。然而,如果我们忘记保护 API,接口可能会泄漏数据和暴露太多的细节。默认情况下,DRF 使用BrowsableAPIRenderer来呈现可浏览的 API。我们可以通过只暴露JSONRenderer来改变这种行为。这种配置可以放在decoupled_dj/settings/production.py,如清单 7-18 所示。

...
REST_FRAMEWORK = {**REST_FRAMEWORK,
   "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"]
}
...

Listing 7-18decoupled_dj/setting/production.py - Disabling the Browsable API in Production

这只会在生产中禁用可浏览 API。

部署分离的 Django 项目

现代云环境为部署 Django 提供了无限可能。

不可能涵盖每一种部署风格,不包括 Docker、Kubernetes 和无服务器设置。相反,在这一节中,我们为 Django 的制作采用了最传统的设置之一。在流行的自动化工具 Ansible 的帮助下,我们部署了 Django、NGINX 和 Gunicorn。本章的源代码中包含了一个可行的行动手册,它有助于在您自己的服务器上复制设置。从目标机器的准备到 NGINX 的配置,下面几节涵盖了我们到目前为止构建的项目的部署理论。

Note

Ansible 剧本的源代码在 https://github.com/valentinogagliardi/decoupled-dj/blob/chapter_07_security_deployment/deployment/site.yml 如何启动行动手册的说明可以在自述文件中找到。

准备目标机器

要部署 Django,我们需要所有必需的包:NGINX、Git(Python 的新版本)和用于请求 SSL 证书的 Certbot。

Ansible 行动手册涵盖了这些包的安装。在这一章中,为了简单起见,我们跳过 Postgres 的安装。建议读者查看 PostgreSQL 下载页面,查看安装说明。在目标系统上,Django 项目还应该有一个非特权用户。一旦完成了这些先决条件,就可以开始配置反向代理 NGINX 了。

Note

Ansible playbook 希望 Ubuntu 作为部署使用的操作系统;不低于 Ubuntu 20.04 LTS 的版本就足够了。

配置 NGINX

在典型的生产安排中,NGINX 工作在系统的边缘。

它接收来自用户的请求,处理 SSL,并将这些请求转发给 WSGI 或 ASGI 服务器。Django 住在窗帘后面。为了配置 NGINX,在这个例子中,我们使用域名decoupled-django.com和子域static.decoupled-django.com。典型 Django 项目的 NGINX 配置至少由三部分组成:

  • 一个或多个upstream声明

  • Django 主要入境点的申报

  • 用于服务静态文件的server声明

deployment/templates/decoupled-django.com.j2文件包括整个配置;在这里,我们只是概述了设置的一些细节。upstream指令指示 NGINX 关于 WSGI/ASGI 服务器的位置。清单 7-19 显示了相关配置。

upstream gunicorn {
   server 127.0.0.1:8000;
}

Listing 7-19deployment/templates/decoupled-django.com.j2 - Upstream Configuration for NGINX

在第一个server块中,我们告诉 NGINX 将主域的所有请求转发给upstream,如清单 7-20 所示。

server {
   server_name {{ domain }};

   location / {
       proxy_pass http://gunicorn;
       proxy_set_header Host $host;
       proxy_set_header X-Real-IP $remote_addr;
       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
       proxy_set_header X-Forwarded-Proto $scheme;
   }

   ## SSL configuration is managed by Certbot
}

Listing 7-20deployment/templates/decoupled-django.com.j2 - Server Configuration for NGINX

这里的{{ domain }}是剧本中声明的一个变量。这里重要的是proxy_pass指令,它将请求转发给 Gunicorn。此外,在这一节中,我们还为代理设置了头,这些头在每个请求中被传递给 Django。特别是,我们有:

  • X-Real-IPX-Forwarded-For,它们确保 Django 获得真实访问者的 IP 地址,而不是代理的地址

  • X-Forwarded-Proto,它告诉 Django 客户端使用哪种协议进行连接(HTTP 或 HTTPS)

Gunicorn 和 Django 的生产要求

在第三章,我们介绍了异步 Django,我们使用 Uvicorn 在 ASGI 下本地运行 Django。在生产中,我们可能想用 Gunicorn 运行 Uvicorn。为此,我们需要为生产配置依赖关系。在requirements文件夹中,创建一个名为production.txt的新文件。在这个文件中,我们声明了 ASGI 部件的所有依赖项,如清单 7-21 所示。

-r ./base.txt
gunicorn==20.0.4
uvicorn==0.13.1
httptools==0.1.1
uvloop==0.15.2

Listing 7-21requirements/production.txt - Production Requirements

这个文件应该放在 Git repo 中,因为它将在部署阶段使用。现在让我们看看如何为生产准备我们的 Vue.js 应用。

与 Django 一起准备生产中的 Vue.js

在第六章中,我们看到了如何在开发中为 Django 下的 Vue.js 服务。我们在 Vue.js 应用的根文件夹中配置了vue.config.js和一个名为.env.staging的文件。这一次,我们将发运生产中的产品。这意味着我们需要一个生产 Vue.js 包,它应该由 NGINX 提供服务,而不是来自 Django。关于静态文件,在生产中,Django 想知道在哪里可以找到 JavaScript 和 CSS。这是在STATIC_URL中配置的,如清单 7-22 所示,摘自本章开头。

...
STATIC_URL=https://static.decoupled-django.com/
STATIC_ROOT=static/
...

Listing 7-22decoupled_dj/settings/.env.production.example - Static Configuration for Production

注意我们用的是 https://static.decoupled-django.com ,这个子域必须在 NGINX 中配置。清单 7-23 显示了子域配置。

...
server {
   server_name static.{{ domain }};

   location / {
       alias /home/{{ user }}/code/static/;
   }
}
...

Listing 7-23deployment/templates/decoupled-django.com.j2 - Ansible Template for NGINX

这里,{{ user }}是在 Ansible 剧本中定义的另一个变量。在设置好 Django 和 NGINX 之后,为了配置 Vue.js,使它“知道”它将从上面的子域得到服务,我们需要在billing/vue_spa中创建另一个环境文件,命名为.env.production,其内容如清单 7-24 所示。

VUE_APP_STATIC_URL=https://static.decoupled-django.com/billing/

Listing 7-24billing/vue_spa/.env.production - Production Configuration for Vue.js

这告诉 Vue.js 它的包将从一个特定的子域/路径提供服务。文件就绪后,如果我们移动到billing/vue_spa文件夹,我们可以运行以下命令:

npm run build -- --mode production

这将在static/billing中构建优化的 Vue.js 包。我们现在需要将这些文件推送到 Git repo。这样做之后,在下一节中,我们将最终看到如何从这个回购开始部署项目。

Note

在现实世界的项目中,生产 JavaScript 包不会被直接推送到源代码控制中。相反,在所有测试套件通过之后,持续集成/部署系统负责构建生产资产,或者 Docker 映像。

部署

在本地构建用于生产的 Vue 并将文件提交给 repo 之后,我们需要将实际代码部署到目标机器上。

为此,我们以在前面步骤中创建的非特权用户身份登录(Ansible playbook 定义了一个名为decoupled-django的用户)或者使用 SSH 登录。完成后,我们将回购克隆到一个文件夹中,为了方便起见,可以称之为code:

git clone --branch chapter_07_security_deployment https://github.com/valentinogagliardi/decoupled-dj.git code

该命令从指定的分支chapter_07_security_deployment中克隆项目的 repo。代码准备就绪后,我们移动到新创建的文件夹,并激活一个 Python 虚拟环境:

cd code
python3.8 -m venv venv
source venv/bin/activate

接下来,我们使用以下命令安装生产依赖项:

pip install -r requirements/production.txt

在运行 Django 之前,我们需要为生产配置环境文件。这个文件必须放在decoupled_dj/settings/.env中。管理这个文件时必须格外小心,因为它包含敏感的凭证和 Django 密钥。特别是,。env文件不应该进入源代码控制。清单 7-25 概括了生产环境的配置指令。

ALLOWED_HOSTS=decoupled-django.com,static.decoupled-django.com
DEBUG=no
SECRET_KEY=!changethis!
DATABASE_URL=psql://decoupleddjango:localpassword@127.0.0.1/decoupleddjango
STATIC_URL=https://static.decoupled-django.com/
STATIC_ROOT=static/

Listing 7-25decoupled_dj/settings/.env.production.example - Environment Variables for Production

这个文件的一个例子可以在decoupled_dj/settings/.env.production.example.中的源 repo 中找到。有了这个文件,我们可以用下面的命令将 Django 切换到生产环境:

export DJANGO_SETTINGS_MODULE=decoupled_dj.settings.production

最后,我们可以用collectstatic收集静态资产并应用迁移:

python manage.py collectstatic --noinput
python manage.py migrate

第一个命令将静态文件复制到/home/decoupled-django/code/static,由 NGINX 拾取。在 Ansible 行动手册中,有一系列任务可以自动执行这里介绍的所有步骤。在运行项目之前,我们可以创建一个超级用户来访问受保护的路由:

python manage.py createsuperuser

为了进行测试,仍然在/home/decoupled-django/code中,我们可以用下面的命令运行 Gunicorn:

gunicorn decoupled_dj.asgi:application -w 2 -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 --log-file -

Ansible playbook 还包括一个 Systemd 服务,用于在引导时设置 Gunicorn。如果一切顺利,我们可以访问 https://decoupled-django.com/77randomAdmin@33/ ,用超级用户凭证登录网站,访问我们 Vue.js app 所在的 https://decoupled-django.com/billing/ 。图 7-1 显示了我们工作的结果。

img/505838_1_En_7_Fig1_HTML.jpg

图 7-1

Django 和 Vue.js 应用在生产中部署

同样,Ansible 行动手册也涵盖了来自 Git repo 的部署。对于大多数项目,Ansible 是设置和部署 Django 项目的良好起点。现在其他的选择是 Docker 和 Kubernetes,越来越多的团队已经将它们完全内化到他们的部署工具链中。

Note

这是提交到目前为止所做的更改,并将工作推送到 Git repo 的好时机。你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_07_security_deployment 找到本章的源代码。

摘要

这一章我们讲了很多。我们检查了安全和部署。在此过程中,您了解到:

  • Django 在默认情况下非常安全,但是在公开 REST API 时必须采取额外的措施

  • Django 不是一个人在工作;像 NGINX 这样的反向代理对于生产设置来说是必须的

  • 部署 Django 有很多种方法;像 Ansible 这样的配置工具在大多数情况下都能很好地工作

在下一章,我们将介绍如何使用 Next.js,React 框架,作为 Django 的前端。

额外资源

八、Django REST 和 Next.js

本章涵盖:

  • Django 作为内容回购

  • React 及其生态系统

  • Vue.js 及其生态系统简介

在本章中,在讨论了安全性和部署之后,我们回到本地工作站,用 Next.js、React 生产框架和 TypeScript 来构建一个博客。

Django 作为一个无头 CMS

基于 REST 和 GraphQL 的解耦架构促进了近年来一种新趋势的兴起:无头 CMS。

有了专门处理输入/输出的数据和序列化的后端,我们可以创建与后端完全分离的消费者前端。这些前端不仅限于充当单页面应用,还可以从后端检索数据来构建静态网站。在本章中,我们将介绍 Next.js,这是一个用于服务器端渲染和静态站点生成的 React 框架。

构建博客应用

Django 上有无数的书籍和教程使用博客应用作为向初学者介绍这个奇妙框架的最直接的方式。

它可能不是最令人兴奋的应用,但是在我们的例子中,它是使用 Django 作为 JavaScript 框架的内容存储库的完美候选。我们开始吧。

Note

本章假设您在回购根decoupled-dj中,Python 虚拟环境处于活动状态,环境变量DJANGO_SETTINGS_MODULE设置为decoupled_dj.settings.development

构建模型

对于我们的博客应用,我们需要一个Blog模型。这个模型应该连接一个User。每个User也应该能够访问其博客文章。首先,我们在blog/models.py中创建模型,如清单 8-1 所示。

from django.db import models
from django.conf import settings

class Blog(models.Model):
   class Status(models.TextChoices):
       PUBLISHED = "PUBLISHED"
       UNPUBLISHED = "UNPUBLISHED"

   user = models.ForeignKey(
       to=settings.AUTH_USER_MODEL,
       on_delete=models.PROTECT,
       related_name="blog_posts"
   )
   title = models.CharField(max_length=160)
   body = models.TextField()
   status = models.CharField(
       max_length=15,
       choices=Status.choices,
       default=Status.UNPUBLISHED
   )
   created_at = models.DateTimeField(auto_now_add=True)

Listing 8-1blog/models.py - Model for the Blog App

在这个模型中,我们为博客条目定义了一些最常见的字段:

  • title:博客条目的标题

  • body:博客条目的文本

  • created_at:创建日期

  • status:条目是否发布

user字段中,我们有一个User的外键,并适当地配置了related_name,这样用户就可以通过 ORM 中的.blog_posts属性访问它的帖子。我们可以添加更多的字段,比如 slug,但是对于本章的范围来说,这些已经足够了。

启用应用

模型就绪后,我们在decoupled_dj/settings/base.py中启用应用,如清单 8-2 所示。

INSTALLED_APPS = [
   ...
   "blog.apps.BlogConfig",
]

Listing 8-2decoupled_dj/settings/base.py - Enabling the Blog App

最后,我们应用迁移:

python manage.py makemigrations
python manage.py migrate

当我们在那里时,让我们在数据库中创建几个博客条目。首先我们打开一个 Django shell:

python manage.py shell_plus

然后我们创建条目(>>>是 shell 提示符):

>>> juliana = User.objects.create_user(username="jul81", name="Juliana", email="juliana@acme.io")
>>> Blog.objects.create(title="Exploring Next.js", body="Dummy body", user=juliana)
>>> Blog.objects.create(title="Decoupled Django", body="Dummy body", user=juliana)

我们以后会需要这些条目,所以这一步不能跳过。有了这个应用,我们现在就可以在继续之前构建 REST 逻辑了。

构建 REST 后端

我们的目标是将Blog模型暴露给外界。这样,任何 JavaScript 客户机都可以检索博客条目。正如我们在第五章中对计费应用所做的,我们需要连接 DRF 的基础:序列化器和视图。在下一节中,我们将为Blog构建一个序列化器,并为公开博客条目构建两个视图。

构建序列化程序

为了构建我们的 REST API,我们在blog中创建了一个名为api的新 Python 包。在这个包中,我们放置了 REST API 的所有逻辑。首先,让我们用清单 8-3 中的序列化程序在blog/api/serializers.py创建一个新文件。

from blog.models import Blog
from rest_framework import serializers

class BlogSerializer(serializers.ModelSerializer):
   class Meta:
       model = Blog
       fields = ["title",
                 "body",
                 "created_at",
                 "status",
                 "id"]

Listing 8-3blog/api/serializers.py - DRF Serializer for the Blog Model

这个序列化器没有什么神秘的:它公开了模型的字段,减去了user。保存并关闭文件。有了序列化器,我们就可以构建视图和 URL 配置了。

构建视图和 URL

对于这个项目,我们需要两个视图:

  • 一个ListAPIView来暴露整个帖子列表

  • 一个RetrieveAPIView暴露单个条目

我们在blog/api/views.py的一个新文件中创建视图,如清单 8-4 所示。

from .serializers import BlogSerializer
from blog.models import Blog
from rest_framework.generics import ListAPIView, RetrieveAPIView

class BlogList(ListAPIView):
   serializer_class = BlogSerializer
   queryset = Blog.objects.all()

class BlogDetail(RetrieveAPIView):
   serializer_class = BlogSerializer
   queryset = Blog.objects.all()

Listing 8-4blog/api/views.py - REST Views for Our Blog

接下来,我们在blog/urls.py的新文件中创建一个 URL 配置。像往常一样,我们给这个配置一个app_name,这有助于在根 URL 配置中命名应用,如清单 8-5 所示。

from django.urls import path
from .api.views import BlogList, BlogDetail

app_name = "blog"

urlpatterns = [
   path("api/posts/",
        BlogList.as_view(),
        name="list"),
   path("api/posts/<int:pk>",
        BlogDetail.as_view(),
        name="detail"),
]

Listing 8-5blog/urls.py - URL Configuration for the Blog App

最后,我们在decoupled_dj/urls.py中包含了我们博客的 URL 配置,如清单 8-6 所示。

from django.urls import path, include

urlpatterns = [
   ...
   path("blog/", include("blog.urls", namespace="blog")),
]

Listing 8-6blog/urls.py - Project URL Configuration

运行 Django 开发服务器后,我们应该能够在http://localhost:8000/blog/api/posts/访问端点。这将是 Next.js 的数据源。

Note

为了避免被本章的认证所困扰,可以暂时在decoupled_dj/setting/base.py中注释DEFAULT_PERMISSION_CLASSES

React 生态系统简介

React 是一个用于构建用户界面的 JavaScript 库,风靡了 web 开发。

通过组件、独立的标记单元和 JavaScript 代码来构建用户界面的 React 方法并不新鲜。然而,由于其灵活性,React 获得了巨大的人气,超过 Angular 和 Vue.js 成为构建单页面应用的首选库。在接下来的部分中,我们将回顾 React 基础知识,并介绍 Next.js,React 生产框架。

重新引入反应

大多数时候,用户界面不是一个单一的整体:它们由独立的单元组成,每个单元控制整个界面的一个特定方面。

例如,如果我们想到一个<select> HTML 元素,我们可能会注意到,在一个典型的应用中,它很少只出现一次。相反,它可以在同一个界面中多次使用。一开始,web 开发人员(包括我自己)通过一遍又一遍地复制粘贴相同的标记来重用应用的一部分。然而,这种方法经常导致不可持续的混乱。过去的问题是:“我如何重用这个标记及其 JavaScript 逻辑”?React 填补了这个巨大的空白,它仍然在某种程度上影响着 web 平台:缺乏原生组件,即可重用的标记和逻辑。

Note

值得注意的是,Web 组件(用于构建界面的原生组件)已经成为现实,但该规范仍有许多不完善之处。

React 支持基于组件的方法来构建用户界面。最初,React 组件是作为 ES2015 类构建的,因为它们能够保留内部状态。随着钩子的出现,React 组件可以作为简单的 JavaScript 函数构建,如清单 8-7 所示。

import React, { useState } from "react";

export default function Button(props) {
 const [text, setText] = useState("");
 return (
   <button onClick={() => setText("CLICKED")}>
     {text || props.initialText}
   </button>
 );
}

Listing 8-7React Component Example

在这个例子中,我们将一个Button组件定义为一个 JavaScript 函数。在组件中,我们使用useState钩子来保持内部状态。当我们点击按钮时,onClick处理程序(它映射到click DOM 事件)触发setText(),改变组件的内部状态。此外,组件从外部获取props,即一个只读对象,它获取任意数量的属性,组件可以使用这些属性向用户呈现数据。一旦我们创建了一个组件,我们就可以无限地重用它,如清单 8-8 所示。

import Button from "./Button";

export default function App() {
 return (

     <Button initialText="CLICK ME" />
     <Button initialText="CLICK ME" />

 );
}

Listing 8-8React Component Usage Example

这里我们有一个嵌套了我们的Button两次的App根组件。从外面我们经过一个initialText属性。React 组件并不总是那么简单,但是这个例子总结了 React 的重要理论,并为下一节铺平了道路。

Next.js 简介

构建单页应用可能看起来很容易。我们已经习惯于使用 create-react-app 和 Vue CLI 等工具来创建新的 SPA 项目。

这些工具给人一种工作已经完成的错觉,这在某种程度上是真实的。现实是,在生产中事情并不那么简单。根据项目的不同,我们需要路由、高效的数据获取、搜索引擎优化、国际化以及性能和图像优化。Next.js 是 React 的一个框架,旨在减轻反复手动设置的负担,并为开发人员提供一个自以为是的生产就绪环境。

在第二章中,我们简单讨论了通用 JavaScript 应用,触及了在后端和前端之间共享和重用代码的能力。Next.js 正好属于这一类工具,因为它使开发人员能够编写服务器端呈现的 JavaScript 应用。Next.js 有两种主要的操作模式:

  • 服务器端渲染

  • 静态站点生成

在接下来的部分中,我们将在用 React 和 TypeScript 构建我们的博客前端时研究这两者。需要注意的是,这些框架不能直接与 Django 集成,因为它们有自己的服务器,由 Node.js 操作。它处理路由、认证、国际化,以及介于两者之间的一切。在这种安排中,像 Django 这样的框架只通过 REST 或 GraphQL API 为 Next.js 提供数据。

构建 Next.js 前端

首先,我们初始化一个 Next.js 项目。从根项目文件夹decoupled_dj/中,启动以下命令:

npx create-next-app --use-npm next-blog

这将在decoupled_dj/next-blog中创建项目。项目就绪后,进入文件夹:

cd next-blog

在 Next.js 项目文件夹中,安装 TypeScript 和几个其他类型定义,一个用于 Node、js,另一个用于 React:

npm i typescript @types/react @types/node --save-dev

安装完成后,使用以下命令创建 TypeScript 的配置文件:

touch tsconfig.json

在这个文件中,根据我们希望 TypeScript 执行的严格程度,我们可以将strict选项设置为false。然而,对于大多数项目,我们可能希望将其设置为true。文件就绪后,启动 Next.js 开发服务器:

npm run dev

这将在http://localhost:3000开始 Next.js。如果一切顺利,您应该会从控制台看到以下输出:

ready - started server on 0.0.0.0:3000, url: http://localhost:3000
We detected TypeScript in your project and created a tsconfig.json file for you.

从那里,我们准备好编写我们的第一个组件。

页面和路由

Next.js 的基础理论围绕着页面的概念。

如果我们查看新创建的项目,应该会看到一个名为pages的文件夹。在这个文件夹中,我们可以定义子文件夹。例如,通过在pages/posts创建一个新文件夹,当运行 Next.js 项目时,我们可以访问http://localhost:3000/posts/。没什么特别刺激的。有趣的部分来自 React 组件。放置在pages中的任何.js.jsx.ts.tsx文件都成为 Next.js 的一个页面。为了理解 Next.js 如何工作,我们先从固定数据开始一步一步地创建一个页面,稍后介绍数据获取。

Note

对于 Next.js 部分,从现在开始我们在decoupled_dj/next-blog中工作。必须从该路径开始,在适当的子文件夹中创建每个建议的文件。

我们将在 Next.js 中创建一个简单的页面。对于下面的例子,在pages/posts/index.tsx中创建一个新文件,下面的 React 组件如清单 8-9 所示。

const BlogPost = () => {
 return (
   <div>
     <h1>Post title</h1>
     <div>
       <p>Post body</p>
     </div>
   </div>
 );
};

export default BlogPost;

Listing 8-9pages/posts/index.tsx - A First Next.js Page

这是一个 React 组件,也是 Next.js 的一个页面。让我们运行开发服务器:

npm run dev

现在,我们可以前往http://localhost:3000/posts,我们应该能够看到一个简单的页面,其中包含我们放在 React 组件中的内容。确实很有趣,但是对于一个动态网站来说有点没用。如果我们想显示不同的博客文章,也许可以通过id获取它们,该怎么办?

在 Next.js 中,我们可以使用动态路由按需构建页面。例如,用户应该能够访问http://localhost:3000/posts/2并在那里看到想要的内容。为此,我们需要将组件的文件名从index.ts改为:

[id].tsx

通过这样做,Next.js 将响应对http://localhost:3000/posts/$id的任何请求,其中$id是我们可以想象的任何数字 ID 的占位符。有了这些信息,组件就可以根据id从 REST API 中获取数据,对于 Next.js 来说,这就变成了一个 URL 参数。有了这些知识,让我们在进入数据获取之前用类型声明来丰富组件。清除我们一分钟前创建的组件中的所有内容,并将下面的代码放入pages/posts/[id].tsx,如清单 8-10 所示。

enum BlogPostStatus {
 Published = "PUBLISHED",
 Unpublished = "UNPUBLISHED",
}

type BlogPost = {
 title: string;
 body: string;
 created_at: string;
 status: BlogPostStatus;
 id: number;
};

const BlogPost = ({ title, body, created_at }: BlogPost) => {
 return (
   <div>
     <header>
       <h1>{title} </h1>
       <span>Published on: {created_at}</span>
     </header>
     <div>
       <p>{body}</p>
     </div>
   </div>
 );
};

export default BlogPost;

Listing 8-10pages/posts/[id]tsx - Blog Component for the Corresponding Next.js Page

该组件是用 TypeScript 静态类型化的。这个文件中有三种特定的类型脚本符号。这里有一个解释:

  • BlogPostStatus : TypeScript enum,为博客文章定义一组可能的状态。它映射了 Django 模型中定义的嵌套的Status类。

  • BlogPost:定义 React 组件属性的类型脚本type。它映射模型的字段(减去user)。

  • BlogPost:在组件参数中使用,强类型化我们的道具。

有了这个组件,我们现在就可以定义数据获取逻辑,用相应的数据填充每个博客文章。

Note

在 TypeScript 中,枚举在编译过程中会产生大量的 JavaScript 代码。这个问题的解决方案是 const enums,但是 Babel 不支持它们,Next.js 使用它们将 TypeScript 编译成 JavaScript。

数据提取

如前所述,Next.js 可以在两种模式下运行:

  • 服务器端渲染

  • 静态站点生成

使用服务器端呈现,页面是根据每个请求构建的,非常像传统的服务器端呈现的网站。想想 Django 模板或 Rails。在这种模式下,当用户点击相应的路径时,我们可以获取每个请求的数据。在 Next.js 中,这是通过getServerSideProps完成的。这应该是一个异步方法,从 React 组件所在的同一个文件中导出。在getServerSideProps中我们需要注意两件事:

  • 获取所需的数据

  • 至少返回一个props对象

一旦这些都完成了,Next.js 将负责把props传递给我们的 React 组件。清单 8-11 展示了一个函数的示例框架,包括类型。

export const getServerSideProps: GetServerSideProps = async (context) => {
 // fetch data
 return { props: {} };
};

Listing 8-11getServerSideProps Skeleton

context对象参数携带关于请求、响应和一个params对象的信息,我们可以在这个对象上访问请求参数。为了方便起见,我们将从context中析构params。让我们将这个函数添加到pages/posts/[id].tsx中,如清单 8-12 所示,并带有相应的数据获取逻辑。

import { GetServerSideProps } from "next";

const BASE_URL = "http://localhost:8000/blog/api";

export const getServerSideProps: GetServerSideProps = async ({ params }) => {
 const id = params?.id;

 const res = await fetch(`${BASE_URL}/posts/${id}`);

 if (!res.ok) {
   return {
     notFound: true,
   };
 }

 const json = await res.json();
 const { title, body, created_at, status } = json;

 return { props: { title, body, created_at, status } };
};

Listing 8-12pages/posts/[id].tsx - Data Fetching Logic for the Page

这段代码需要解释一下:

  • 我们导入了GetServerSideProps类型,它用于为实际函数提供类型

  • getServerSideProps中:

    • 我们从params得到id

    • 我们从 Django REST API 获取数据

    • 如果来自 API 的响应是否定的,我们返回notFound

    • 如果后端返回博客文章,我们将为组件返回一个props对象

Note

getServerSideProps有更多的返回属性,便于特定的用例。请查看官方文档以了解更多信息。

有了这些代码,我们就可以进行测试了。首先,Django 必须逃跑。在终端中,转到decoupled_dj并启动 Django:

python manage.py runserver

在启动 Next.js 的另一个终端中,运行开发服务器(如果它还没有运行的话)(从decoupled_dj/next_blog文件夹中):

npm run dev

现在,访问http://localhost:3000/posts/1http://localhost:3000/posts/2。你应该会看到一篇博文,如图 8-1 所示。

img/505838_1_En_8_Fig1_HTML.jpg

图 8-1

Next.js 回应了一篇博文的详细路线

如您所见,这种方法完美无缺。在这种模式下,Next.js 在将页面发送给用户之前检索数据。但是对于博客来说,这不是最好的方法:没有比静态网站更好的让搜索引擎开心的网站了。下一节将解释如何使用 Next.js 实现数据获取和静态站点生成。

静态站点生成

每当我们想要向用户显示一篇博客文章时,调用 REST API 有点低效。

博客更适合作为静态页面。除了在每个请求上获取数据,Next.js 还支持在构建时获取数据。在这种模式下,我们可以以静态 HTML 的形式生成页面及其相应的数据,Next.js 将把这些数据提供给我们的用户。为了实现这一点,我们需要结合使用 Next.js 中的另外两个方法:getStaticPathsgetStaticProps。上一节的getServerSideProps和这些方法有什么区别?

getServerSideProps用于在服务器端渲染中异步获取每个请求的数据。也就是说,当用户到达给定的页面时,它必须等待一段时间,因为 Next.js 服务器必须从给定的源(REST API 或 GraphQL 服务)获取数据。这种方法对于动态且变化很大的数据来说很方便。

相反,getStaticProps用于在构建时异步获取数据。也就是说,当我们运行npm run buildyarn build时,Next.js 会创建一个产品包,其中包含它需要的所有 JavaScript,以及任何标记为静态的页面。清单 8-13 显示了该函数的示例框架。

import { GetStaticProps } from "next";

const BASE_URL = "http://localhost:8000/blog/api";

export const getStaticProps: GetStaticProps = async (_) => {
 const res = await fetch(`${BASE_URL}/posts/1`);

 const json = await res.json();
 const { title, body, created_at, status } = json;

 return { props: { title, body, created_at, status } };
};

Listing 8-13getStaticProps Example

注意我们是如何具体调用http://localhost:8000/blog/api/1的,这是相当有限的。在构建阶段之后,Next.js 生成相应的静态页面。通过运行npm run startyarn start,Next.js 可以为我们的网站服务。当页面导出getStaticProps时,相关组件接收从该方法返回的props。然而,为了让我们的例子工作,页面必须有一个固定的路径,比如1.tsx。事先知道我们后端的每篇博客文章的 ID 是不切实际的。这就是getStaticPaths发挥作用的地方。使用这种方法,结合使用getStaticProps,我们可以生成一个路径列表,供getStaticProps用来获取数据。为了利用静态站点生成,让我们更改pages/posts/[id].tsx,以便它使用getStaticPathsgetStaticProps而不是getServerSideProps,如清单 8-14 所示。

import { GetStaticPaths, GetStaticProps } from "next";

const BASE_URL = "http://localhost:8000/blog/api";

export const getStaticPaths: GetStaticPaths = async (_) => {
 const res = await fetch(`${BASE_URL}/posts/`);
 const json: BlogPost[] = await res.json();
 const paths = json.map((post) => {
   return { params: { id: String(post.id) } };
 });

 return {
   paths,
   fallback: false,
 };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
 const id = params?.id;

 const res = await fetch(`${BASE_URL}/posts/${id}`);

 if (!res.ok) {
   return {
     notFound: true,
   };
 }

 const json: BlogPost = await res.json();
 const { title, body, created_at, status } = json;

 return { props: { title, body, created_at, status } };
};

Listing 8-14pages/posts/[id].tsx - Data Fetching at Build Time with getStaticPaths and getStaticProps

这里,getStaticProps的逻辑与上一节中getServerSideProps的逻辑相同。然而,我们也有getStaticPaths。在此功能中,我们:

  • 调用 REST API 获取来自http://127.0.0.1:8000/blog/api/posts/的所有帖子的列表

  • 生成并返回路径数组

这个路径数组很重要,必须具有以下形状:

   paths: [
     { params: { id: 1 } },
     { params: { id: 2 } },
     { params: { id: 3 } },
   ]

在我们的代码中,它由以下代码片段生成:

...
 const paths = json.map((post) => {
   return { params: { id: String(post.id) } };
 });
...

getStaticPaths的返回对象中,还有一个fallback选项。它用于显示不包含在paths中的任何路径的 404 页面。此时,我们可以使用以下命令来构建博客:

npm run build

注意 Django 一定还在另一个终端中运行。一旦构建就绪,我们应该在.next/server/pages/posts中看到静态页面。为了给博客提供服务(至少目前是在本地),我们运行以下命令:

npm run start

现在,访问http://localhost:3000/posts/1http://localhost:3000/posts/2,你应该会看到一篇博文,如图 8-1 所示。显然,对于用户来说,这个版本和之前的getServerSideProps版本没有什么变化。但是如果我们停止 Django API,我们仍然可以访问我们的博客,因为现在它只是一堆静态页面,更重要的是,静态 HTML 的性能增益是无可匹敌的。

Note

getStaticPropsgetServerSideProps并不互斥。根据用例,Next.js 项目中的页面可以使用这两者。例如,站点的一部分可以作为静态 HTML,而另一部分可以作为单页应用。

我们谈了很多。这里展示的概念在一个简单的博客中可能看起来有点太多了。毕竟,单凭 Django 就足以处理这种类型的网站。但是越来越多的团队正在采用这种设置,前端开发人员可以使用他们最喜欢的工具来塑造 UI,从单页应用到静态网站。

部署 Next.js

Next.js 是一个成熟的 React 框架。它需要自己的 Node.js 服务器,这个服务器已经集成了,这也意味着它不能在 Django 内部运行。通常,部署的结构是 Django 后端和 Next.js 系统位于各自独立的机器/容器上。

对 Django 使用 React

2019 年,我在博客上发表了一篇题为“Django REST with React”的帖子。

该教程说明了如何配置 webpack 环境以在正确的 Django 静态文件夹中构建 React,就像我们在第五章中使用 Vue.js 所做的一样。博文中概述的方法本质上并不坏,但它可能不适合较大的团队,并且由于 webpack 中潜在的突破性变化,它可能变得难以跟上变化。一个解决方案是流行的 create-react-app,它抽象出了所有与 webpack 和 Babel 相关的平凡细节。然而,要让 Django 使用 create-react-app,必须指示 Django 寻找 react 静态文件。这包括调整TEMPLATESSTATICFILES_DIRS中的DIRS键。

Vue.js 生态系统

对于一个不经意的观察者来说,现代 web 开发领域似乎完全由 React 主导。

这与事实相去甚远。Vue.js 和 Angular 占据了不错的市场份额。Vue.js 有一个称为 Nuxt.js 的框架,在功能上等同于 Next.js。没有足够的空间来涵盖本书中的所有内容,但是考虑到 Next.js 和 Nuxt.js 几乎具有完全相同的功能,习惯于使用 Vue.js 的开发人员可以将本章中看到的相同概念应用到他们选择的框架中。事实上,我们鼓励您尝试一下 Nuxt.js。

Note

这是提交到目前为止所做的更改并将工作推送到 Git repo 的好时机。你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_08_django_rest_meets_next 找到本章的源代码。

摘要

本章将 Next.js 项目与博客应用 REST API 配对。在此过程中,您了解了:

  • React 的类型脚本

  • Next.js 操作模式

  • Next.js 数据提取

在下一章,我们将更加认真地对待从后端到前端的整个范围的单元和功能测试。

额外资源

九、解耦的世界中的测试

本章涵盖:

  • 面向大量使用 JavaScript 的接口的功能测试

  • Django REST 框架的单元测试

在本章中,我们将测试添加到我们的应用中。在第一部分中,我们介绍了用 Cypress 对用户界面进行功能测试。在第二部分,我们转移到 Django 的单元测试。

Note

本章假设您在 repo root decoupled-dj中,Python 虚拟环境处于活动状态。

功能测试简介

通常情况下,软件开发中的测试是事后才想到的,这是一种被忽视的浪费时间的行为,会减慢开发速度。

对于用户界面的功能测试来说尤其如此,因为要测试的 JavaScript 交互量与日俱增。这种感觉可能来自于对 Selenium for Python 等工具的记忆,不幸的是,这些工具非常慢,并且很难用于测试 JavaScript 接口。然而,随着新型 JavaScript 工具的出现,这种情况在最近几年有了很大的改变,它减轻了测试单页面应用的负担。这些工具使得从用户的角度为界面编写测试变得容易。功能测试也是捕捉 UI 中的回归的一个很好的方法,也就是说,在一个不相关的特性的开发过程中,意外引入的 bug,而这个特性在变化之前工作得很好。在接下来的部分中,我们将介绍 Cypress,一个 JavaScript 的测试运行器。

Cypress 入门

Cypress 是 NPM 上提供的一个 JavaScript 包,可以放入我们的项目中。在只有一个 JavaScript 前端需要测试的项目中,可以将 Cypress 安装在 React/Vue.js 应用的根项目文件夹中。

在我们的例子中,由于我们可能有不止一个 JavaScript 应用要测试,我们可以将 Cypress 安装在根项目文件夹decoupled-dj中。首先,用以下命令初始化一个package.json:

npm init -y

接下来,安装 Cypress:

npm i cypress --save-dev

完成后,您可以使用以下命令第一次启动 Cypress:

./node_modules/.bin/cypress open

该命令将打开一个窗口,并在根项目文件夹中创建一个名为cypress的新文件夹。还创建了许多子文件夹,如下面的目录列表所示:

cypress
├── fixtures
├── integration
├── plugins
└── support

对于本章的范围,我们可以安全地忽略这些文件夹,除了integration。我们将在那里进行测试。有了 Cypress,我们可以在接下来的部分中编写我们的第一个测试了。

了解计费应用的功能测试

还记得第章第 6 的计费 app 吗?现在是时候为它编写功能测试了。

这个应用有一个表单,用户可以填写字段来创建一个新的发票,然后点击创建发票在后端创建新的实体。图 9-1 显示了第六章的最终形式。

img/505838_1_En_9_Fig1_HTML.jpg

图 9-1

第六章的发票表格

我们不要忘记,我们希望在功能测试中从用户的角度测试界面。通过一个漂亮流畅的语法,Cypress 允许我们像用户一样一步一步地与元素交互。我们如何知道如何测试和测试什么?编写功能测试应该是自然而然的事情。我们需要想象用户将如何与界面交互,为我们想要测试的每个 HTML 元素编写选择器,然后验证该元素的行为是否正确,或者它是否响应用户交互而改变。对于我们的表单,我们可以确定以下步骤。用户应该:

  • 选择发票的客户

  • 至少编制一个包含数量、说明和价格的发票行

  • 为发票选择一个日期

  • 选择发票的到期日

  • 点击创建发票提交表格

所有这些步骤都必须翻译成 Cypress 逻辑,它本质上只是 JavaScript。在下一节中,我们为表单的<select>元素编写第一个测试。

创建第一个测试

在我们测试的第一次迭代中,我们与界面的两个部分进行交互。特别是,我们:

  • 将表单作为目标

  • select互动

在 Cypress 中,这两个步骤转化为方法调用,看起来几乎像简单的英语。首先,用清单 9-1 中所示的代码在cypress/integration/Billing.spec.js中创建一个新的测试。

context("Billing app", () => {
 describe("Invoice creation", () => {
   it("can create a new invoice", () => {
     cy.visit("http://localhost:8080/"
);
     cy.get("form").within(() => {
       cy.get("select").select("Juliana - juliana@acme.io");
     });
   });
 });
});

Listing 9-1cypress/integration/Billing.spec.js - A First Test Skeleton in Cypress

让我们来分解这些说明:

  • 包含整个测试,并给它一个有凝聚力的组织

  • describe()包含了我们测试的一个方面,通常与context()结合使用

  • it()是实际的试块

  • cy.visit()导航至应用主页

  • 是 Cypress 本身,它提供了许多选择元素和与元素交互的方法

  • cy.get("form")选择界面中的第一个表单

  • within()告诉 Cypress 从先前选择的元素内部运行每个后续命令

  • cy.get("select")选择表单内的<select>元素

  • cy.get("select").select("Juliana - juliana@acme.io")select中选取值为"Juliana - juliana@acme.io"<option>元素

Note

由于我们的界面相当简单,我们不会过多关注高级选择器和最佳实践。鼓励读者阅读 Cypress 文档以了解更多关于高级技术的信息。

这段代码的突出之处在于每条语句的表现力。有了流畅的描述性方法,我们就可以像对用户期望的那样,瞄准 HTML 元素并与之交互。理论上,我们的测试已经准备好运行了,但是有一个问题。<select>需要来自网络的数据。这个数据来自 Vue 组件的mounted()方法,如清单 9-2 所示。

...
 mounted() {
   fetch("/billing/api/clients/")
     .then(response => {
       if (!response.ok) throw Error(response.statusText);
       return response.json();
     })
     .then(json => {
       this.users = json;
     });
 }
...

Listing 9-2billing/vue_spa/src/components/InvoiceCreate.vue - The Form’s Mounted Method

事实上,如果我们启动 Vue.js 应用,我们会在控制台中看到以下错误:

Proxy error: Could not proxy request /billing/api/clients/ from localhost:8080 to http://localhost:8000

这来自 Vue.js 开发服务器,我们在开发中指示它代理所有网络请求到 Django REST API。如果不在另一个终端中运行 Django,我们真的无法测试任何东西。这就是 Cypress 网络拦截发挥作用的地方。原来我们可以拦截网络通话,直接从 Cypress 回复。为此,我们需要通过在cy.visit()之前添加一个名为cy.intercept()的新命令来调整我们的测试,如清单 9-3 所示。

context("Billing app", () => {
 describe("Invoice creation", () => {
   it("can create a new invoice", () => {
     cy.intercept("GET", "/billing/api/clients", {
       statusCode: 200,
       body: [
         {
           id: 1,
           name: "Juliana",
           email: "juliana@acme.io",
         },
       ],
     });

     cy.visit("http://localhost:8080/");
     cy.get("form").within(() => {
       cy.get("select").select(
         "Juliana - juliana@acme.io"
       );
     });
   });
 });
});

Listing 9-3cypress/integration/Billing.spec.js - Adding Network Interception to the Test

从这个片段中,我们可以看到cy.intercept()需要:

  • 要拦截的 HTTP 方法

  • 要拦截的路径

  • 用作响应存根的对象

在这个测试中,我们拦截来自 Vue 组件的网络请求,在它到达后端之前停止它,并使用静态响应体进行回复。通过这样做,我们可以完全避免接触后端。现在,为了进行测试,我们可以运行测试套件。从我们安装 Cypress 的decoupled-dj文件夹中,我们用下面的命令运行测试运行程序:

./node_modules/.bin/cypress open

Note

为了方便起见,最好在package.json中创建一个e2e脚本作为cypress open的别名。

这将打开一个新窗口,我们可以从中选择运行哪个测试,如图 9-2 所示。

img/505838_1_En_9_Fig2_HTML.jpg

图 9-2

Cypress 欢迎页面

通过点击规格文件Billing.spec.js,我们可以运行测试,但在此之前,我们需要启动 Vue.js 应用。从另一个终端,进入billing/vue_spa并运行以下命令:

npm run serve

一旦完成,我们就可以重新加载测试,让 Cypress 来完成这项工作。测试运行人员将检查测试块中的每个命令,就像真实用户一样。当测试结束时,我们应该看到所有的绿色,这是测试通过的标志。图 9-3 显示了测试窗口。

img/505838_1_En_9_Fig3_HTML.jpg

图 9-3

第一次通过测试

Cypress 中的网络拦截对于没有后端的工作来说确实很方便。后端团队可以通过文档、实际的 JavaScript 对象或 JSON 装置与前端团队共享预期的 API 请求和响应。另一方面,前端开发人员可以构建 UI,而不必在本地运行 Django。在下一节中,我们通过测试表单输入来完成表单的测试。

填写并提交表格

为了提交表单,Cypress 需要填写所有必填字段。

为此,我们采用了一组用于表单交互的 Cypress 方法:

  • type()在输入栏中键入

  • submit()触发我们表单上的submit事件

使用type(),我们不仅可以输入表单域,还可以与日期输入交互。这非常方便,因为我们的表单有两个类型为date的输入。例如,要用 Cypress 选择并键入一个date输入,我们可以使用下面的命令:

cy.get("input[name=date]").type("2021-03-15");

在这里,我们用合适的选择器定位输入,并使用type()填充字段。这种方法适用于任何形式的输入。对于文本输入,这是一个瞄准 CSS 选择器并输入的问题。当页面上存在两个或更多相同类型的输入时,Cypress 需要知道哪一个是目标。如果我们只对页面上的第一个元素感兴趣,我们可以使用以下说明:

cy.get("input[type=number]").first().type("1");

这里我们告诉 Cypress 只选择页面上的第一个输入数字。如果我们想与两个或更多同类元素交互呢?作为一个快速的解决方法,我们可以使用.eq()通过索引来定位元素。索引从0开始,很像 JavaScript 数组:

cy.get("input[type=number]").eq(0).type("1");
cy.get("input[type=number]").eq(1).type("600.00");

在这个例子中,我们指示 Cypress 将页面上类型为number的两个输入作为目标。有了这些知识,并着眼于我们的应用的 HTML 表单结构,我们可以将清单 9-4 中所示的代码添加到我们之前的测试中。

...
     cy.get("form").within(() => {
       cy.get("select").select(
         "Juliana - juliana@acme.io"
       );

       cy.get("input[name=date]").type("2021-03-15");
       cy.get("input[name=due_date]").type("2021-03-30");
       cy.get("input[type=number]").eq(0).type("1");
       cy.get("input[name=description]").type(
         "Django consulting"
       );
       cy.get("input[type=number]").eq(1).type("5000.00");
     });

     cy.get("form").submit();
...

Listing 9-4cypress/integration/Billing.spec.js - Filling the Form with Cypress

在这里,我们填写所有必需的输入、两个日期、发票描述和价格。最后,我们提交表单。虽然这个测试通过了,但是 Vue.js 并不高兴,因为它不能将POST请求路由到/billing/api/invoices/。在控制台中,我们可以看到以下错误:

Proxy error: Could not proxy request /billing/api/invoices/ from localhost:8080 to http://localhost:8000

这是 Cypress 拦截可以提供帮助的另一种情况。在提交表单之前,我们再声明一次拦截,这次是针对/billing/api/invoices。还有,我们断言 API 调用是前端触发的;参见清单 9-5 。

...
     cy.intercept("POST", "/billing/api/invoices", {
       statusCode: 201,
       body: {},
     }).as("createInvoice");

     cy.get("form").submit();
     cy.wait("@createInvoice");
...

Listing 9-5cypress/integration/Billing.spec.js - Adding Another Network Interception to the Test

这里的新指令是as()cy.wait()。有了as(),我们可以别名 Cypress 选择,在这种情况下,也是我们的网络拦截。相反,使用cy.wait(),我们可以等待网络调用发生,并有效地测试前端正在对后端进行实际的 API 调用。有了这个测试,我们可以再次运行 Cypress,它现在应该给我们所有的绿色,如图 9-4 所示。

img/505838_1_En_9_Fig4_HTML.jpg

图 9-4

我们发票表单的完整测试套件

这就结束了我们的应用面向客户端的功能测试。虽然范围有限,但这个测试有助于说明 Cypress 的基本原理。到目前为止,我们编写的测试以 Vue.js 为目标,没有考虑 Django。为了使我们的功能测试尽可能接近真实世界,我们还需要测试从 Django 内部提供的 JavaScript 前端。这是在本章末尾留给用户的一个练习。现在让我们来关注一下后端。我们的 REST API 也需要测试。

单元测试简介

与功能测试相反,单元测试旨在确保单个代码单元(如函数或类)按预期工作。

在这一章中,我们不讨论 JavaScript 的单元测试,因为我们已经看到了 Cypress 的功能测试,而要正确地解决 React 和 Vue.js 的单元测试,另一章是不够的。相反,我们将看到如何对 Django 后端应用单元测试。从用户的角度来看,功能测试是检查 UI 功能的无价工具。相反,单元测试确保我们的 Django 后端和它的 REST API 为我们的 JavaScript 前端提供正确的数据。功能测试和单元测试并不相互排斥。一个项目应该具备这两种类型的测试,才能被认为是健壮的和对变化有弹性的。在下一节中,我们将看到如何用 Django 测试工具测试 Django REST 框架。

Django REST 框架中的单元测试

开箱即用,Django 使得从一开始就拥有优秀的代码覆盖率成为可能。代码覆盖率是测试覆盖了多少代码的度量。Django 是一个包含电池的框架,它带有一套强大的工具,如 API 视图和一个奇妙的 ORM,这些工具已经由 Django 贡献者和核心开发人员进行了测试。然而,这些测试还不够。

在构建项目时,我们需要确保视图、模型、序列化器和任何定制的 Python 类或函数都经过了正确的测试。幸运的是,Django 为我们提供了一套方便的单元和集成测试工具,比如TestCase类。Django REST 框架在此基础上添加了一些定制工具,包括:

  • APISimpleTestCase用于测试没有数据库支持的 API

  • 用于测试 API 和数据库支持

为 DRF 视图编写单元测试与为传统的 Django 视图编写测试没有太大的不同。清单 9-6 中的例子说明了入门的最小测试结构。

from rest_framework.test import APITestCase
from rest_framework.status import HTTP_403_FORBIDDEN
from django.urls import reverse

class TestBillingAPI(APITestCase):
   @classmethod
   def setUpTestData(cls):
       pass

   def test_anon_cannot_list_clients(self):
       response = self.client.get(reverse("billing:client-list"))
       self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)

Listing 9-6Django REST Test Example

在这个例子中,我们子类化APITestCase来声明一个新的测试套件。在这个类中,我们可以看到一个名为setUpTestData()的类方法,它对我们的测试初始化数据很有用。接下来,我们将第一个测试声明为类方法:test_anon_cannot_list_clients()是我们的第一个测试。在这个块中,我们用测试 HTTP 客户端self.client.get()调用 API 视图。然后,我们检查从视图中得到的响应代码是否是我们所期望的,在本例中是一个403 Forbidden,因为用户没有经过身份验证。在接下来的小节中,我们将按照示例的结构为 REST 视图编写测试。

用于测试的 Django 设置

在开始之前,让我们配置 Django 项目进行测试。通常,我们需要在测试中稍微改变一些设置,因此为测试环境创建一个分离设置是很方便的。为此,在decoupled_dj/settings/testing.py中创建一个新文件,内容如清单 9-7 所示。

from .base import *  # noqa

Listing 9-7decoupled_dj/settings/testing.py - Split Settings for Testing

到目前为止,这个文件除了导入基本设置之外没有做任何事情,但是这确保了我们可以在需要时覆盖任何配置。

安装依赖项并配置测试要求

我们现在准备好安装用于测试的依赖项了。

对于我们的项目,我们将使用两个方便的库:pytestpytest-django。一起使用它们可以简化我们运行测试的方式。例如,当与pytest-django一起使用时,pytest可以自动发现我们的测试,所以我们不需要添加导入到我们的__init__.py文件中。我们还将使用model-bakery,它可以减轻我们在测试中创建模型的负担。要安装这些库,请运行以下命令:

pip install pytest pytest-django model-bakery

接下来,在requirements/testing.txt中创建一个测试需求文件,并添加清单 9-8 中所示的行。

-r ./base.txt
model-bakery==1.2.1
pytest==6.2.2
pytest-django==4.1.0

Listing 9-8requirements/testing.txt - Requirements for Testing

我们的设置到此结束。我们现在准备好编写测试了!

概述 Billing REST API 的测试

在编写测试时,理解项目中要测试什么是最具挑战性的任务,尤其是对初学者而言。

很容易迷失在测试实现细节和内部代码中,但实际上,它不应该那么复杂。当决定测试什么时,您需要关注一件事:系统的预期输出。在我们的 Django 应用中,我们公开了 REST 端点。这意味着我们需要看看这个系统是如何使用的,并相应地测试这些界限。在识别了系统的表面之后,对内部逻辑的测试自然就会到来。现在让我们看看我们的计费应用需要测试什么。第五章中的 Vue 前端调用以下端点:

  • /billing/api/clients/

  • /billing/api/invoices/

顺便说一句,这些就是我们和cy.intercept()在柏树上撞毁的相同的终点。这次我们需要用 Django 中的单元测试来覆盖它们,而不是用 Cypress 进行功能测试。但是让我们退后一步,想想我们的测试。在第六章中,我们在 REST API 中添加了认证和权限检查。只有经过身份验证的管理员用户才能调用 API。这意味着我们需要考虑认证,并测试我们没有忘记通过允许匿名用户潜入我们的 API 来实施认证。凭直觉,我们需要编写以下测试:

  • 作为匿名用户,我无法访问客户端列表

  • 作为管理员用户,我可以访问客户端列表

  • 作为匿名用户,我不能创建新发票

  • 作为管理员用户,我可以创建新的发票

让我们在下一节编写这些测试。

测试计费 REST API

首先,在billing中创建一个名为tests的新 Python 包。

在这个文件夹中创建一个名为test_api.py的新文件。在这个文件中,我们将放置我们的测试类,其结构与我们在前一个例子中看到的相同。我们还将所有的测试方法添加到我们的类中,如前一节所述。清单 9-9 展示了这个测试的主干。

from rest_framework.test import APITestCase
from rest_framework.status import HTTP_403_FORBIDDEN, HTTP_200_OK, HTTP_201_CREATED
from django.urls import reverse

class TestBillingAPI(APITestCase):
   @classmethod
   def setUpTestData(cls):
       pass

   def test_anon_cannot_list_clients(self):
       response = self.client.get(reverse("billing:client-list"))
       self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)

   def test_admin_can_list_clients(self):
       # TODO: authenticate as admin
       response = self.client.get(reverse("billing:client-list"))
       self.assertEqual(response.status_code, HTTP_200_OK)

   def test_anon_cannot_create_invoice(self):
       response = self.client.post(
           reverse("billing:invoice-create"), data={}, format="json"
       )
       self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)

Listing 9-9billing/tests/test_api.py - Test Case for the Billing API

这项测试还远未完成。对匿名用户的测试看起来很好,但是我们不能说对管理员也是如此,因为我们在测试中还没有被认证。为了在我们的测试中创建一个 admin 用户(Django 的 staff 用户),我们可以使用来自setUpTestData()model-bakerybaker(),然后在测试客户端使用force_login()方法,如清单 9-10 所示。

from rest_framework.test import APITestCase
from rest_framework.status import HTTP_403_FORBIDDEN, HTTP_200_OK, HTTP_201_CREATED
from django.urls import reverse
from model_bakery import baker

class TestBillingAPI(APITestCase):
   @classmethod
   def setUpTestData(cls):
       cls.admin = baker.make("users.User", is_staff=True)

   def test_anon_cannot_list_clients(self):
       response = self.client.get(reverse("billing:client-list"))
       self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)

   def test_admin_can_list_clients(self):
       self.client.force_login(self.admin)
       response = self.client.get(reverse("billing:client-list"))
       self.assertEqual(response.status_code, HTTP_200_OK)

   def test_anon_cannot_create_invoice(self):
       response = self.client.post(
           reverse("billing:invoice-create"), data={}, format="json"
       )
       self.assertEqual(response.status_code, HTTP_403_FORBIDDEN)

Listing 9-10billing/tests/test_api.py - Authenticating as an Admin in Our Test

有了这个测试,我们现在就可以开始测试了。在终端中,运行以下命令将 Django 切换到测试环境:

export DJANGO_SETTINGS_MODULE=decoupled_dj.settings.testing

然后,运行 pytest:

pytest

如果一切顺利,我们应该会在控制台中看到以下输出:

billing/tests/test_api.py ... [100%]
============= 3 passed in 0.94s ==========

我们的测试通过了!我们现在可以向我们的测试添加最后一个案例:作为一个管理员用户,我可以创建一个新的发票。为此,我们在类中创建了一个新方法。在这个方法中,我们以管理员身份登录,通过提供请求体向 API 发出一个POST请求。我们不要忘记,为了创建发票,我们还必须传递一个项目行列表。这可以在请求体中完成。下面的清单显示了完整的测试方法,其中我们还在请求体之前创建了一个用户。这个用户后来与发票相关联,如清单 9-11 所示。

...
def test_admin_can_create_invoice(self):
   self.client.force_login(self.admin)
   user = baker.make("users.User")
   data = {
       "user": user.pk,
       "date": "2021-03-15",i
       "due_date": "2021-03-30",
       "items": [
           {
               "quantity": 1,
               "description": "Django consulting",
               "price": 5000.00,
               "taxed": True,
           }
       ],
   }
   response = self.client.post(
       reverse("billing:invoice-create"), data, format="json"
   )
   self.assertEqual(response.status_code, HTTP_201_CREATED)
...

Listing 9-11billing/tests/test_api.py - Testing Invoice Creation as an Admin

我们对计费应用 REST API 的单元测试到此结束。除了功能测试,我们还涵盖了后端和前端之间的所有通信。

Note

这是提交到目前为止所做的更改并将工作推送到 Git repo 的好时机。你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_09_testing 找到本章的源代码。

Exercise 9-1: Testing Django and Vue.Js

我们对 Cypress 的功能测试没有考虑到 Vue.js 在生产中是从 Django 的角度提供的。到目前为止,我们孤立地测试了 Vue.js 应用。针对为应用服务的 Django 视图编写一个功能测试。

Exercise 9-2: Testing the Blog App

既然您已经学习了如何使用 Cypress 和 Django 构建和编写测试,那么就为 Next.js 应用编写一组功能测试。也为 blog REST API 编写单元测试。

摘要

在识别和覆盖所有可能的极限情况时,测试通常是一种直觉艺术。本章概述了以下工具和技术:

  • 使用 Cypress 进行功能测试

  • 在 Django 使用 DRF 的测试工具进行单元测试

在下一章,我们转移到下一个大话题:认证。

额外资源

十、Django REST 框架中的认证和授权

本章涵盖:

  • 基于令牌的认证和 JWT 简介

  • 针对单页面应用的基于会话的认证

写一本技术书籍意味着从十亿个主题开始,没有足够的空间来容纳所有的内容。

认证是一个庞大的主题,几乎不可能在一章中深入讨论。有太多的场景:移动应用、桌面应用和单页应用。因为这本书更多的是关于单页应用和 JavaScript 与 Django 的结合,所以本章只关注这两个角色之间的交互。在第一部分中,我们讨论基于令牌的认证。在第二部分中,我们求助于一个久经考验的身份验证流程,与单页面应用配对。

Note

本章的其余部分假设您在 repo root decoupled-dj中,Python 虚拟环境是活动的,并且DJANGO_SETTINGS_MODULE被配置为decoupled_dj.settings.development

基于令牌的身份验证和 JWT 简介

在第六章中,我们用 DRF 和 Vue.js 创建了一个伪解耦的 Django 项目

在第七章中,我们通过添加一个最小形式的认证和授权来加强我们项目的安全性。我们看到了如何使用基于会话的认证来保护从 Django 视图提供的单页面应用。在第八章中,我们添加了 Next.js。为了从我们的 Django 项目开始生成博客,我们必须完全禁用认证。这远远不是最佳的,这让我们想到了前端与 Django 后端完全分离的所有情况。在传统设置中,使用 cookies 和基于会话的身份验证没有太多麻烦。然而,当前端和后端在不同的域上时,认证就变得棘手了。此外,由于会话存储在服务器上,前端和后端之间的会话 cookie 和 CSRF cookie 的交换违反了 REST 的无状态特性。出于这个原因,多年来,社区提出了一种基于令牌的认证形式,称为 JSON Web Token

对于非耦合设置中的身份验证,使用 JWT 进行基于令牌的身份验证现在非常流行,尤其是在 JavaScript 社区中。在 Django,JWT 还没有标准化。接下来是对 JWT、基于令牌的身份验证的介绍,以及对它们潜在缺陷的讨论。

基于令牌的认证:好与坏

无论如何,基于令牌的认证并不是一个新概念。

令牌是一个简单的标识符,前端可以与后端交换,以证明它有权读取或写入后端。在最简单的安排中,解耦的前端向后端发送用户名和密码。另一方面,后端验证用户的凭证,如果它们有效,它就向前端发回一个令牌。令牌通常是简单的字母数字字符串,如下例所示:

9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b

当前端想要向 API 上的受保护资源发出请求时,无论是GET还是POST请求,它都必须通过将令牌包含在请求头中来发回这个令牌。Django REST 框架通过TokenAuthentication方案为基于令牌的认证提供了现成的支持。

在安全性方面,这种认证机制远非刀枪不入。首先,通过网络发送凭证,即用户名和密码,即使在 HTTPS 下也不是最好的方法。此外,一旦我们在前端获得一个令牌,我们需要在整个用户会话期间持久化它,有时甚至超过这个时间。为了持久化令牌,大多数开发人员求助于将它保存在localStorage中,这是一个巨大的错误,实际上给应用带来了一系列全新的风险。localStorage易受 XSS 攻击,攻击者在其控制的网页中注入恶意 JavaScript 代码,引诱用户访问该网页,并窃取任何非HttpOnlycookie,以及保存在localStorage中的任何潜在数据。

相反,就这些令牌的功能而言,它们非常简单。它们不携带关于用户的信息,也不指示用户拥有什么权限。它们被严格地绑定到 Django 和它的数据库,只有 Django 可以将一个给定的用户和它的令牌配对。它们的简单性是这些基本令牌的一个“特征”。尽管有这些限制,基于令牌的身份验证在前端和后端位于不同域的所有情况下都工作得很好。然而,多年来,JavaScript 社区一直在思考创造更多结构化标记的机会。这导致了名为 JSON Web Tokens 的新标准的诞生,它带来了创新,也带来了更多的挑战。

Django 中的 JSON Web 令牌:优势和挑战

JSON Web Token(简称 JWT)是一种标准,它定义了一种在客户机和服务器之间交换身份验证信息的便捷方式。

JWT 令牌与简单的字母数字令牌完全不同。首先,他们已经签字了。也就是说,它们在通过网络发送出去之前由服务器加密,然后在客户机上解密。这是必要的,因为 JWT 令牌包含敏感信息,如果这些令牌被盗,可用于针对受保护的资源进行身份验证。JWT 在 JavaScript/Node.js 社区拥有稳固的市场份额。

相反,在 Django 场景中,它们被认为是一种不安全的身份验证方法。其原因是服务器端的 JWT 实现很难正确,规范中有太多的东西留给实现者去解释,这可能会在不知道的情况下构建一个不安全的 JWT 服务器。要了解更多关于 JWT 的所有安全含义,请查看附加资源中的第一个链接。简而言之,到今天为止,Django 没有对 JWT 的核心支持,这种情况在未来也不会改变。

如果你想在你自己的 Django 项目中使用 JWT,有很多库可以使用,比如django-rest-framework-simplejwt。该库不处理注册流程,而只处理 JWT 的发行阶段。换句话说,从前端我们可以使用用户名和密码的api/token/api/token/refresh/来请求新的令牌或者刷新令牌,如果我们手中有令牌的话。当客户端向服务器请求令牌时,服务器用两个令牌进行回复:访问令牌和刷新令牌。作为一种安全措施,访问令牌通常有一个截止日期。另一方面,当新的访问令牌过期时,客户端使用刷新令牌来请求新的访问令牌。访问令牌用于认证,刷新令牌用于请求新的认证令牌。因为这两个令牌同等重要,所以必须在客户端对它们进行充分保护。

与任何令牌一样,JWT 令牌也经常会遇到同样的问题。大多数开发人员在localStorage中保留 JWT 令牌,这容易受到 XSS 的攻击。这可能比保存一个简单的字母数字标记更糟糕,因为 JWT 在它的主体中携带了更多的敏感信息,即使它是加密的,我们也不能放松对它的保护。为了避免这些风险,开发人员求助于在HttpOnlycookie 中保存 JWT 令牌,巧合的是,这听起来很像最经典的基于会话的认证方法。最后,即使 JWT 令牌便于跨域和移动认证,维护这样的基础设施也可能很困难,并且容易出现安全风险。对于 Django 和单页面应用,有没有简单的认证方法?我们将在下一节探讨这个问题。

针对单页应用的基于会话的认证

最后,Django 项目的认证不应该很复杂,至少对于 web 应用是这样。

事实上,在 NGINX 的帮助下,我们可以使用基于会话的认证来代替令牌,即使是单页应用。在第七章中,我们用传统设置部署了 Django 应用,这是一个为单页面应用服务的 Django 模板。如果我们现在把事情颠倒过来,把一个单页面应用作为 Django 项目的主要入口,会怎么样?在这发生之前,我们需要考虑几个步骤。尤其是,NGINX 应该:

  • 从根位置块提供单页应用

  • 对 Django 的代理 API、auth 和 admin 请求

为此,我们需要对第七章的配置进行必要的调整。让我们看看在接下来的部分需要做什么。

Note

我们将要看到的配置完全独立于第七章中的配置。这是两种不同的方法,都是有效的。

关于生产和发展的一些话

除非您使用 Docker 或虚拟机,否则以下部分提供的场景不容易在本地工作站上复制。

为了尽可能接近现实,我们提供了一个生产环境,其中应用部署在 https://decoupled-django.com/ ,带有有效的 SSL 证书。如果您想要复制相同的环境,您有两种选择:

如果你选择第二种,这里有一些建议:

  • 在 VirtualBox 实例中,将一个 SSH 端口和另一个 web 服务器端口从客户机转发到主机。对于 SSH,您可以为客户机选择 8022,转发到主机上的 22,对于 web 服务器,选择从客户机转发到主机的端口 80。

  • 在主工作站的/etc/hosts文件中,配置 decoupled-django.com 域和 static.decoupled-django.com 子域指向127.0.0.1

虚拟机就绪后,从您的工作站使用以下命令运行 Ansible 行动手册:

ansible-playbook -i deployment/inventory deployment/site.yml --extra-vars "trustme=yes"

这个剧本将配置环境,部署代码,并为 decoupled-django.comstatic.decoupled-django.com 创建一个假的 SSL 证书。一旦完成,在为证书添加例外后,您可以在浏览器中访问 https://decoupled-django.com/

Note

如何运行行动手册的说明可在 https://github.com/valentinogagliardi/decoupled-dj/blob/chapter_10_authentication/README.md#deployment 找到。

为新设置准备 NGINX

作为第一步,我们需要配置 NGINX 来服务根location块上的单页面应用。

清单 10-1 显示了第一个变化。

...
location / {
   alias /home/{{ user }}/code/billing/vue_spa/dist/;
}
...

Listing 10-1deployment/templates/decoupled-django.​com.j2 - NGINX Configuration to Serve the Single-Page Application

这与我们在第七章中看到的不同,在第七章中,项目的主要入口是 Gunicorn。在本例中,我们重用了第六章中的 Vue.js 单页应用,这是一个创建发票的简单表单,但是为了进行测试,我们将它提升为我们项目的主单页应用。这里我们对 NGINX 说,当一个用户访问我们网站的根目录时,发送到/home/decoupled-django/code/billing/vue_spa/dist/中的 Vue.js app。这里的dist是什么?默认情况下,Vue CLI 在 Vue.js 项目的dist文件夹中构建生产 JS 包。这是默认配置,但是在第六章中,我们对其进行了修改,以在静态文件中向 Django 期望的地方发出包。现在我们回到默认值。为了实现这一点,我们还需要稍微调整一下 Vue.js。有了这个配置,通过访问生产中的 https://decoupled-django.com/ ,NGINX 将服务于单页 app。然而,Vue.js 一加载,它就调用billing/api/clients/来获取<select>的客户列表。这导致我们再次调整 NGINX 的配置,以便任何对/api/的请求都被代理到 Gunicorn,从而被代理到 Django。清单 10-2 显示了额外的 NGINX 块。

location ~* /api/ {
   proxy_pass http://gunicorn;
   proxy_set_header Host $host;
   proxy_set_header X-Real-IP $remote_addr;
   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
   proxy_set_header X-Forwarded-Proto $scheme;
}

Listing 10-2deployment/templates/decoupled-django.com.j2 - NGINX Configuration to Proxy API Requests to Django

有了这个改变,API 调用将真正到达 Django。还缺少一个细节:身份验证。这种设置改变了一切。Django 不再负责为单页面应用提供服务,但它确实应该为 API 和登录流提供服务,这是有道理的——下一节将详细介绍。

用 Django 处理登录流

我们希望使用一个单页面应用作为 Django 项目的主要入口点,但是我们还希望使用来自 Django 的基于会话的身份验证。

这就是我们遇到难题的地方。我们如何在不涉及令牌的情况下认证我们的用户?Django 有一个内置的认证系统,是contrib模块的一部分,从中我们可以看到一组处理最常见的认证流程的视图:登录/注销、注册/确认和密码重置。例如,django.contrib.auth.viewsLoginView可以帮助登录流程。然而,我们当前设置的问题是,单页面应用现在已经与 Django 项目完全分离了。

作为一种幼稚的方法,我们可以尝试从 JavaScript 向 Django LoginView发出一个POST请求,但是这些视图受到 CSRF 检查的保护。这也是我们之前遇到的问题,但是现在问题更严重了,因为我们没有任何 Django 视图可以在发出请求之前获取 CSRF 令牌。解决方案?我们可以让 Django 处理认证流程。为此,我们将为认证逻辑创建一个独立的 Django 应用。在根项目文件夹中,运行以下命令:

python manage.py startapp login

接下来,在login/urls.py中创建一个新的 URL 配置,并将清单 10-3 中所示的代码放入其中。

from django.urls import path
from django.contrib.auth.views import LoginView, LogoutView

app_name = "auth"

urlpatterns = [
   path(
       "login/",
       LoginView.as_view(
           template_name="login/login.html",
           redirect_authenticated_user=True
       ),
       name="login",
   ),
   path("logout/", LogoutView.as_view(), name="logout"),
]

Listing 10-3login/urls.py - URL Configuration for Login and Logout Views

这里我们声明了两个路由,一个用于登录,另一个用于注销。LoginView使用自定义的template_name。在login/templates/login/login.html中创建模板,如清单 10-4 所示。

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <title>Login</title>
</head>
<body>
<form method="POST" action="{% url "auth:login" %}">
   {% csrf_token %}
   <div>
       <label for="{{ form.username.id_for_label }}">Username:</label>
       <input type="text" name="username" autofocus maxlength="254" required id="id_username">
   </div>
   <div>
       <label for="{{ form.password.id_for_password }}">Password:</label>
       <input type="password" name="password" autocomplete="current-password" required="" id="id_password">
   </div>
   <input type="hidden" name="next" value="{{ next }}">
   <button type="submit" value="login">
       LOGIN
   </button>
</form>
{{ form.non_field_errors }}
</body>
</html>

Listing 10-4login/templates/login/login.html - Login Form

这是一个简单的 HTML 表单,增加了 Django 模板标签;具体包括{% csrf_token %}。当表单被呈现时,Django 在标记中放置一个隐藏的 HTML 输入,如清单 10-5 所示。

<input type="hidden" name="csrfmiddlewaretoken" value="2TYg60oC0GC2LW7oJEPwBsg2ajZsjJ0n5Wvjqd28J9wMcGBanbnNfkmfT5Qw3juK">

Listing 10-5Django’s CSRF Token in HTML Forms

这个输入的值和POST请求一起发送给 Django LoginView。如果用户的凭证有效,Django 将用户重定向到选择的 URL,并向浏览器发送两个 cookie:csrftokensessionid。为此,我们需要加载登录应用并在decoupled_dj/settings/base.py中配置重定向 URL,如清单 10-6 所示。

INSTALLED_APPS = [
   ...
   "login"
]

...

LOGIN_REDIRECT_URL = "/"

Listing 10-6decoupled_dj/settings/base.py - Enabling the Login App and Configuring the Login Redirect URL

一旦完成,在根配置中包含新的 URL,decoupled_dj/urls.py,如清单 10-7 所示。

urlpatterns = [
   ...
   path("auth/", include("login.urls", namespace="auth")),
]

Listing 10-7decoupled_dj/urls.py - Including the URL from the Login App

最后一步,我们告诉 NGINX 任何对/auth/的请求都必须被代理给 Django,如清单 10-8 所示。

location /auth/ {
   proxy_pass http://gunicorn;
   proxy_set_header Host $host;
   proxy_set_header X-Real-IP $remote_addr;
   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
   proxy_set_header X-Forwarded-Proto $scheme;
}

Listing 10-8deployment/templates/decoupled-django.com.j2 - NGINX Configuration to Proxy Authentication Requests to Django

我们用这种设置实现了什么?NGINX 现回复如下:

在这种安排中,Django 通过基于会话的认证来处理整个认证流程。另一方面,单页面应用只对 Django 进行 API 调用。在这方面,我们需要修复 Vue.js,以便与新的设置一起工作。

Note

你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_10_authentication/deployment/templates/ 找到 NGINX 配置的源代码。

为新设置准备 Vue.js 应用

概括一下,在第六章中,我们配置了vue.config.js.env.staging来让 Django 静态文件与 Vue.js 一起工作

在第七章中,我们添加了另一块拼图,通过配置.env.production使 Vue.js 能够识别它被加载的子域。在本章中,我们可以去掉那些配置。配置文件vue.config.js.env.staging.env.production可以从billing/vue_spa/中移除。通过这样做,当构建产品包时,JavaScript 文件和资产将放在dist文件夹中。这个文件夹通常被排除在源代码控制之外,所以我们需要在目标机器上安装 Node.js 来安装 JavaScript 依赖项,并从/home/decoupled-django/code/billing/vue_spa开始构建捆绑包。一旦完成,我们就可以运行我们的 Vue.js 应用作为 Django 项目的主要入口。

Note

位于 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_10_authentication 的 Ansible playbook 负责安装 Node.js 并构建包。

这种设置的效果是 JavaScript 前端会将 cookies 传递给 Django,而无需我们的任何干预。图 10-1 显示csrftokensessionid随着GET的请求行进至/billing/api/clients

img/505838_1_En_10_Fig1_HTML.jpg

图 10-1

从 JavaScript 到 Django 的 GET 请求包括会话 cookie 和 CSRF 令牌

图 10-2 显示了相同的 cookies,这次是通过POST请求传输的。

img/505838_1_En_10_Fig2_HTML.jpg

图 10-2

从 JavaScript 到 Django 的 POST 请求包括会话 cookie 和 CSRF 令牌

在这个设置中没有任何神奇之处:cookies 可以在同一个源上传播,甚至超过 Fetch。

关于 HttpOnly Cookies 的说明

cookie 是一种不能从 JavaScript 代码中读取的 cookie。

默认情况下,Django 已经确保了sessionid具有HttpOnly属性。这不会中断与fetch的 cookie 交换,因为same-origin的默认行为确保了当调用的 JavaScript 代码与目标 URL 具有相同的来源时,cookie 会被来回发送。至于csrftoken,我们需要让 JavaScript 可以访问它,因为我们将它作为一个头包含在不安全的 HTTP 请求(POST之类的)旁边。

在前端处理认证

既然我们已经配置了 NGINX 来将请求代理到适当的目的地,并且 Django 后端已经准备好处理登录请求,我们就可以在前端处理身份验证了。

好消息是,我们不会手工编写身份验证表单、发送令牌或将其保存在localStorage中。然而,我们需要找到一种绕过HttpOnlycookie 的方法,因为我们不能再从 JavaScript 访问sessionid。常见的做法是通过查看 cookies 来检查用户是否通过了 JavaScript 代码的身份验证。有了sessionid这个HttpOnly饼干,我们就没有这种奢侈了(放松这种保护也不是一个选项)。一个可能的解决方案隐藏在来自 REST API 的错误消息中。任何对 DRF 的未经验证的请求实际上都返回Authentication credentials were not provided,以及一个403 Forbidden错误。在前端,我们可以检查这个信号,然后将用户重定向到/auth/login/。我们开billing/vue_spa/src/App.vue吧。这是我们的 Vue.js 应用的根组件。在这个组件中,我们可以在将用户重定向到登录视图之前检查用户是否通过了身份验证。首先,在模板部分,我们只在通过检查组件状态中的布尔值来验证用户时才呈现InvoiceCreate。清单 10-9 显示了<template>部分的变化。

<template>
 <div id="app">
   <div v-if="isLoggedIn">
     <InvoiceCreate />
   </div>
   <div v-else></div>
 </div>
</template>

Listing 10-9billing/vue_spa/src/App.vue - Checking if the User Is Logged In

在组件的脚本部分,我们组装了以下逻辑:

  • mounted()中,我们调用一个端点

  • 如果我们得到一个200,我们就认为用户通过了身份验证

  • 如果我们得到了一个Forbidden,我们就从 Django REST 框架中检查错误的确切类型

清单 10-10 显示了<script>部分的变化。

<template>
 <div id="app">
   <div v-if="isLoggedIn">
     <InvoiceCreate />
   </div>
   <div v-else></div>
 </div>
</template>

<script>
import InvoiceCreate from "@/components/InvoiceCreate";

export default {
 name: "App",
 components: {
   InvoiceCreate
 },
 data: function() {
   return {
     isLoggedIn: false
   };
 },
 methods: {
   redirectToLogin: function() {
     this.isLoggedIn = false;
     window.location.href = "/auth/login/";
   }
 },
 mounted() {
   fetch("/billing/api/clients/")
     .then(response => {
       if (
         !response.ok &&
         response.statusText === "Forbidden"
       ) {
         return response.json();
       }
       this.isLoggedIn = true;
     })
     .then(drfError => {
       switch (drfError?.detail) {
         case "Authentication credentials were not provided.":
           this.redirectToLogin();
           break;
         default:
           break;
       }
     });
 }
};
</script>

Listing 10-10billing/vue_spa/src/App.vue - Handling Authentication in the Frontend

在这段代码中,我们向选择的端点发出一个 AJAX 请求。如果请求返回一个Forbidden,我们用一个简单的switch语句检查 Django REST 框架给出了什么样的错误。我们可能要检查的第一个错误消息是Authentication credentials were not provided.,这是一个明显的信号,表明我们试图在没有凭证的情况下访问受保护的资源。如果您担心通过字符串的方式检查身份验证或权限看起来不太好,因为 Django 迟早会更改错误消息并返回一个意外的字符串,根据我的经验,前端和后端开发人员之间总是有某种契约来商定他们可以从对方那里得到哪些响应体或错误消息。如果需要考虑字符串,可以很容易地将其抽象成常量。这还不算前端和后端必须始终置于强大的测试套件之下。

Note

在这个例子中,我们使用fetch()来避免引入额外的依赖关系。另一个有效的选择是axios,它有一个方便的拦截器特性。

有了这个逻辑,我们可以添加更多的检查,比如权限,我们将在下一节中看到。这不是最聪明的实现,但它完成了工作,更重要的是,它使用了一种久经考验的身份验证方法。React 也可以使用同样的方法:我们可以从 NGINX 提供单页面应用,Django 隐藏在后台。值得注意的是,只有当 Django 和单页面在同一个域中提供服务时,这种设置才有效。使用 NGINX 和 Docker 很容易实现这一点。对于客户端位于不同域的所有配置,都需要基于令牌的身份验证。有了身份验证部分,现在让我们探索 Django REST 框架中的授权。

Note

在前面的例子中,我们使用了window.location来重定向用户。如果使用 Vue 路由器,代码必须调整使用this.$router.push()

Django REST 框架中的授权和许可

一旦用户登录,我们就处于流程的中间。

认证是整个故事中“你是谁”的一部分。接下来是“你能做什么”的部分。在第七章中,我们通过只允许管理员用户访问来锁定我们的 API。清单 10-11 显示了decoupled_dj/settings/base.py中应用的配置。

REST_FRAMEWORK = {
   "DEFAULT_AUTHENTICATION_CLASSES": [
       "rest_framework.authentication.SessionAuthentication",
   ],
   "DEFAULT_PERMISSION_CLASSES": [
       "rest_framework.permissions.IsAdminUser"
   ],
}

Listing 10-11decoupled_dj/setting/base.py - Adding Permissions Globally in the DRF

为了在前端进行测试,我们可以在 Django 项目中创建一个无特权用户。打开 Django shell 并运行下面的 ORM 指令:

User.objects.create_user(username="regular-user", password="insecure-pass")

这将在数据库中创建一个新用户。如果我们试图在auth/login/用这个用户登录,Django 会像预期的那样重定向回主页,但是一旦我们登陆到那里,我们就不会在界面上看到任何东西。这是因为我们的 JavaScript 前端不能处理 Django REST 框架用You do not have permission to perform this action响应的情况。我们可以在调用billing/api/clients的浏览器控制台的网络标签中看到这个错误。通过 DRF 权限,我们可以让用户访问 REST 视图。权限不仅可以在配置级别设置,还可以在每个视图上设置粒度。为了允许经过身份验证的用户访问,而不仅仅是管理员访问billing/api/clients,我们可以使用IsAuthenticated权限类。要应用该权限,打开billing/api/views.py并调整代码,如清单 10-12 所示。

...
from rest_framework.permissions import IsAuthenticated

class ClientList(ListAPIView):
   permission_classes = [IsAuthenticated]

   serializer_class = UserSerializer
   queryset = User.objects.all()
...

Listing 10-12billing/api/views.py - Applying Permissions on the View Level

通过这一更改,任何经过身份验证的用户都可以访问该视图。在前端,我们可以通过在switch语句中添加另一个检查来处理权限错误,它在来自 API 的响应中寻找You do not have permission to perform this action,并向我们的用户显示一条用户友好的消息。当然,许可的故事并没有到此为止。在 Django REST 框架中,我们可以定制权限,在对象级别授予权限,等等。文档几乎涵盖了所有可能的用例。

Note

这是提交到目前为止所做的更改并将工作推送到 Git repo 的好时机。你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_10_authentication 找到本章的源代码。

摘要

您从本章中学到了一些重要的要点:

  • 切勿在localStorage中存储令牌或其他敏感数据

  • 尽可能使用基于会话的身份验证来保护单页面应用

在下一章中,我们将从 Ariadne 开始探索 Django 中的 GraphQL。

额外资源

  • django 的 jwts

十一、Django 的 GraphQL 和 Ariadne

本章涵盖:

  • GraphQL 模式、操作和解析器

  • 阿里阿德涅

  • React 和 Django 与 GraphQL

在本章的第一部分,我们用一个 GraphQL API 扩充了第六章的计费应用。稍后,我们开始将 React/TypeScript 前端连接到 GraphQL 后端。

Note

本章假设您在 repo root decoupled-dj中,Python 虚拟环境是活动的,并且DJANGO_SETTINGS_MODULE被配置为decoupled_dj.settings.development

Django Ariadne 入门

第一章讲述了 GraphQL 的基础知识。

我们了解到,为了从 GraphQL API 获取数据,我们通过一个POST请求发送一个查询。为了改变数据库中的数据,我们发送了一个所谓的突变。现在是时候通过 Ariadne 将 GraphQL 引入我们的 Django 项目了,Ariadne 是一个用于 Python 的 GraphQL 库。Ariadne 使用模式优先的方法。在 schema-first 中,GraphQL API 由 GraphQL 模式定义语言以字符串或.graphql文件的形式形成。

安装 Ariadne

首先,我们在 Django 项目中安装 Ariadne:

pip install ariadne

安装完成后,我们更新requirements/base.txt以包含新的依赖项。接下来,我们在decoupled_dj/settings/base.py中启用 Ariadne,如清单 11-1 所示。

INSTALLED_APPS = [
      ...
      "ariadne.contrib.django",
]

Listing 11-1decoupled_dj/settings/base.py - Enabling Ariadne in INSTALLED_APPS

我们还需要确保TEMPLATES中的APP_DIRS被设置为True,如清单 11-2 所示。

TEMPLATES = [
      {
      ...
      "APP_DIRS": True,
      ...
]

Listing 11-2decoupled_dj/settings/base.py - Template Configuration

这就是我们要开始做的所有事情。启用 Ariadne 后,我们现在准备开始构建我们的 GraphQL 模式。

设计 GraphQL 模式

GraphQL 强制使用模式定义语言来定义模式,这是 GraphQL API 的主要构建块。

这与 REST 有很大的不同,在 REST 中,后端代码通常是第一位的,只有在后面我们才会为 API 生成文档。GraphQL 中的这个概念是颠倒的:我们首先创建模式,它既作为文档,又作为 GraphQL API 和它的消费者之间的契约。如果没有模式,我们就不能向 GraphQL API 发送查询或变更:我们会得到一个错误,因为模式驱动的正是消费者可以向服务询问的内容。模式中有什么?GraphQL 模式包含消费者可用的所有操作和模型实体的定义。在考虑我们的模式之前,让我们回顾一下计费应用中涉及的实体。我们有以下端点:

  • /billing/api/clients/

  • /billing/api/invoices/

此外,我们还有以下型号:

  • User

  • Invoice,用一个外键连接到User

  • ItemLine,用一个外键连接到Invoice

我们的 GraphQL 模式必须包含所有这些模型的形状(只要我们想在 API 中公开它们),加上每个允许的 GraphQL 操作的形状,以及它们的返回值。这在实践中意味着什么?为了向 GraphQL API 发送一个getClients查询,我们首先需要在模式中定义它的形状。以清单 11-3 中的查询为例。

query {
  getClients {
      name
  }
}

Listing 11-3Example of a Typical GraphQL Query

如果没有相应的模式,查询将会失败,并出现以下错误:

Cannot query field 'getClients' on type 'Query'

GraphQL 模式中所有可用的查询、变异和订阅都以操作命名。

有了这些知识,让我们定义我们的第一个模式。在billing/schema.graphql创建一个新文件。注意它有一个.graphql扩展名。大多数编辑器和 ide 为语言提供了补全和智能感知,因此将模式放在自己的文件中是有意义的。作为一种选择,我们也可以将模式直接写成代码中的三重引号字符串。在这个例子中,我们采用第一种方法。在该模式中,我们将为计费应用定义所有实体,外加从数据库获取所有客户端的第一个查询。我们从哪里得到物体的形状?由于我们的应用已经有了一个包含 DRF 的 REST API,我们可以查看一下billing/api/serializers.py,看看那里公开了哪些字段。毕竟,DRF 序列化器是 Django 模型和世界其他部分之间的公共接口,因此它是 GraphQL 模式的一种独特方式。此外,我们应该在billing/models.py中查看我们的模型是如何连接的,以便在 GraphQL 中表达相同的关系。清单 11-4 展示了我们第一个基于模型和 DRF 序列化器中定义的适当字段的模式。

enum InvoiceState {
   PAID
   UNPAID
   CANCELLED
}

type User {
   id: ID
   name: String
   email: String
}

type Invoice {
   user: User
   date: String
   dueDate: String
   state: InvoiceState
   items: [ItemLine]
}

type ItemLine {
   quantity: Int
   description: String
   price: Float
   taxed: Boolean
}

type Query {
   getClients: [User]
}

Listing 11-4billing/schema.graphql - First Iteration of the GraphQL Schema for the Billing App

从模式中我们可以立即识别出 GraphQL 标量类型:IDStringBooleanIntFloat。这些类型描述了对象的每个字段有什么类型的数据。我们还可以注意到一个enum类型,它代表了一个Invoice的不同状态。在我们的序列化器中,我们没有为Invoice公开state字段,但是现在是将它包含在 GraphQL 模式中的好时机。至于我们数据库中的模型实体,请注意UserInvoiceItemLine是带有字段的定制对象类型。另一件突出的事情是描述模型关系的方式。从上到下,我们可以看到:

  • Invoice为一个user字段为一个User

  • Invoice作为一个items字段添加到一个ItemLine列表中

  • getClients查询解析(返回)一个User列表

实际上,该模式是表达能力的最佳体现。同样值得注意的是,由于许多原因,这个模式还不完善。例如,InvoicedatedueDate表示为String。这不是 Django 所期望的。我们稍后将修复这些不一致之处。渴望看到 Django 中的 GraphQL API 是什么样子,在下一节中,我们将在 Ariadne 中加载模式。

在 Ariadne 中加载模式

我们离在 Django 建立第一个 GraphQL 端点只有几步之遥。

为此,我们需要加载模式并使其可执行。在billing/schema.py创建另一个文件,它应该包含清单 11-5 中的代码。

from ariadne import load_schema_from_path, gql, make_executable_schema
from ariadne import ObjectType
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent

schema_file = load_schema_from_path(BASE_DIR / "schema.graphql")
type_defs = gql(schema_file)

query = ObjectType("Query")

"""
TODO: Write resolvers
"""

schema = make_executable_schema(type_defs, query)

Listing 11-5billing/schema.py - Loading the Schema in Ariadne

在这个代码片段中,我们从 Ariadne 导入必要的工具。特别是:

  • 从文件系统加载我们的模式文件

  • gql验证模式

  • ObjectType创建根类型(查询、变异和订阅)

  • make_executable_schema将模式连接到解析器,Python 函数对数据库执行查询

在第一个片段中,我们还没有任何解析器。我们将在下一节中添加一些。还需要注意的是,这段代码还没有对数据库做任何事情。在下一节中,我们将billing/schema.py连接到一个 Django URL。

连接 GraphQL 端点

在对 GraphQL API 进行查询之前,我们需要将我们的模式连接到 Django URL。

为此,我们在billing/urls.py中添加一个名为graphql/的 URL,如清单 11-6 所示。

from django.urls import path
from .views import Index
from .api.views import ClientList, InvoiceCreate
from ariadne.contrib.django.views import GraphQLView
from .schema import schema

app_name = "billing"

urlpatterns = [
   path("", Index.as_view(), name="index"),
   path("api/clients/", ClientList.as_view(), name="client-list"),
   path("api/invoices/", InvoiceCreate.as_view(), name="invoice-create"),
   path("graphql/", GraphQLView.as_view(schema=schema), name="graphql"),
]

Listing 11-6billing/urls.py - Enabling the GraphQL Endpoint

在 Django 的这个 URL 配置中,我们从 Ariadne 导入了GraphQLView,它的工作方式很像普通的 CBV。保存文件并用python manage.py runserver启动 Django 项目后,我们可以前往http://127.0.0.1:8000/billing/graphql/。这应该会打开 GraphQL Playground,一个用于探索 GraphQL API 的交互式工具。在操场上,我们可以发出查询、变更,或者只是探索模式和集成文档。图 11-1 显示了我们之前创建的模式,它出现在 GraphQL 平台上。

img/505838_1_En_11_Fig1_HTML.jpg

图 11-1

阿里阿德涅的 GraphQL 游乐场。它公开了模式和方便的文档

图 11-2 显示了我们第一个查询的自动生成文档。

img/505838_1_En_11_Fig2_HTML.jpg

图 11-2

我们的第一个查询出现在自动生成的文档中

为了向这个 GraphQL 端点发送查询,我们可以使用 playground 或者更实际的 JavaScript 客户机。快速测试,也可以用curl。打开另一个终端并启动以下命令:

curl -X POST --location "http://127.0.0.1:8000/billing/graphql/" \
      -H "Accept: application/json" \
      -H "Content-Type: application/json" \
      -d "{
      \"query\": \"query { getClients { name } }\"
      }"

作为响应,您应该得到以下结果:

{"data": {"getClients": null } }

这表明我们的 GraphQL API 正在工作,但是我们的查询没有返回任何东西。在下一节中,我们将讨论解决方案。

使用解析器

GraphQL 中的解析器是什么?最简单的形式是,解析器是一个负责查询数据库或任何其他来源的可调用的(一个方法或函数)。GraphQL 中的解析器是公共 API 和数据库之间的桥梁,这也是等式中最难的部分。GraphQL 只是一个规范,大多数实现都不包含数据库层。幸运的是,Django 有一个很好的 ORM 来减轻直接处理原始 SQL 查询的负担。让我们重温一下我们的模式,特别是清单 11-7 中显示的查询。

type Query {
      getClients: [User]
}

Listing 11-7Our First Query

这个查询有一个名为getClients的字段。为了让我们的getClients查询工作,我们需要将这个查询字段绑定到一个解析器。为此,我们回到billing/schema.py添加第一个解析器,如清单 11-8 所示。

from ariadne import load_schema_from_path, gql, make_executable_schema
from ariadne import ObjectType
from pathlib import Path
from users.models import User

BASE_DIR = Path(__file__).resolve().parent

schema_file = load_schema_from_path(BASE_DIR / "schema.graphql")
type_defs = gql(schema_file)

query = ObjectType("Query")

@query.field("getClients")
def resolve_clients(obj, info):
   return User.objects.all()

schema = make_executable_schema(type_defs, query)

Listing 11-8billing/schema.py - Adding a Resolver to the Mix

这个文件中最显著的变化是我们导入了User模型,并用@query.field()修饰了一个解析器函数。在这个例子中,解析器resolve_clients()得到两个参数:objinfoinfo包含关于被请求字段的细节,更重要的是,它携带一个附加了 HTTP 请求的上下文对象。这是同步运行的 Django 项目的一个实例WSGIRequestobj是从父解析器返回的任何值,因为解析器也可以嵌套。在我们的例子中,我们现在不使用这些参数。保存文件后,我们可以在每个用户的查询中包含任意多的字段。例如,我们可以在 GraphQL Playground 中发送清单 11-9 中的查询。

query {
  getClients {
      id
      name
      email
  }
}

Listing 11-9A GraphQL Query for Fetching All Clients

注意,在 Ariadne 的 Python 代码中,我们没有为idnameemail定义解析器。我们简单地为getClients查询定义了一个解析器。为每个字段手工定义一个解析器是不切实际的,幸运的是,GraphQL 涵盖了我们。Ariadne 可以用默认解析器的概念处理这些字段,这个概念来自于 Ariadne 构建的graphql-core。我们的查询现在将返回清单 11-10 中所示的响应。

{
    "data": {
        "getClients": [
            {
                "id": "1",
                "name": "Juliana",
                "email": "juliana@acme.io"
            },
            {
                "id": "2",
                "name": "John",
                "email": "john@zyx.dev"
            }
        ]
    }
}

Listing 11-10The Response from the GraphQL API

如果数据库中还没有用户,请从 shell 中创建一些:

python manage.py shell_plus

要创建两个用户,请运行以下查询(>>>是 shell 提示符):

>>> User.objects.create_user(username="jul81", name="Juliana", email="juliana@acme.io")
>>> User.objects.create_user(username="john89", name="John", email="john@zyx.dev")

Note

到目前为止,我们讨论了数据库,但是 GraphQL 几乎可以处理任何数据源。Gatsby 是这种能力的一个很好的例子:例如,Markdown 插件可以解析来自.md文件的数据。

祝贺您创建了第一个 GraphQL 端点!现在让我们了解一下 GraphQL 中的查询参数。

在 GraphQL 中使用查询参数

到目前为止,我们从数据库中获取了所有用户。

如果我们只想取一个呢?这就是查询参数发挥作用的地方。让我们想象下面的 GraphQL 查询:

query {
 getClient(id: 2) {
   name
   email
 }
}

这里我们有一个查询字段,使用起来很像一个函数。id是“函数”的自变量。相反,在主体中,我们声明了要为单个用户获取的字段。为了支持这个查询,模式必须知道它。这意味着我们需要:

  • 在模式中定义新的查询字段

  • 创建一个解析器来完成该字段

首先,在billing/schema.graphql中,我们将新字段添加到现有的Query对象中,如清单 11-11 所示(所有现有的对象类型必须保持不变)。

type Query {
   getClients: [User]
   getClient(id: ID!): User
}

Listing 11-11billing/schema.graphql - Adding a New Field to the Query

在新的getClient字段中,注意带有标量类型IDid参数,现在后面跟了一个感叹号。这个符号意味着参数是不可空的:由于显而易见的原因,我们不能使用空 ID 进行查询。

Note

不可空约束可应用于任何 GraphQL 对象字段或列表。

有了良好的模式,我们现在开始在billing/schema.py中定义新的解析器,如清单 11-12 所示(为了简洁,我们只显示了新的解析器)。

...
@query.field("getClient")
def resolve_client(obj, info, id):
   return User.objects.get(id=id)
...

Listing 11-12billing/schema.py - An Additional Resolver for Fulfilling the New Query

在这个新的解析器中,除了objinfo,我们还可以看到第三个名为id的参数。这个参数将由我们的 GraphQL 查询传递给解析器。通常,查询中定义的任意数量的参数都会传递给相应的解析器。我们现在可以在操场上发出清单 11-13 中所示的查询。

query {
 getClient(id: 2) {
   name
   email
 }
}

Listing 11-13Retrieving a Single User from the GraphQL Playground

服务器应该返回清单 11-14 中所示的响应。

{
 "data": {
   "getClient": {
     "name": "John",
     "email": "john@zyx.dev"
   }
 }
}

Listing 11-14The GraphQL Response for a Single User

在这次数据获取之旅之后,现在是时候做一些更有挑战性的事情了:添加带有突变的数据。

关于模式优先和代码优先的一句话

在我们的 GraphQL API 中添加了几个查询和解析器之后,我们已经可以发现一个模式了。

每当我们需要一个新字段时,我们就更新两个文件:

  • billing/schema.graphql

  • billing/schema.py

这个问题的解决方案是将文本模式放在代码中。考虑以下示例:

type_defs = gql(
   """
   type User {
       id: ID
       name: String
       email: String
   }
   """
)

我们没有从文件中加载模式,而是直接将它放在gql中。这是一种方便的方法。然而,使用代码优先的方法,从代码开始派生和生成模式的能力比处理字符串更加灵活。石墨烯和 Strawberry 正是遵循这条路径。

实施突变

GraphQL 中的突变是副作用,即意味着改变数据库状态的操作。

在第六章中,我们在 REST 后端工作,它接受来自前端的POST请求来创建新的发票。为了创建新的发票,我们的后端需要:

  • 要与发票关联的用户 ID

  • 发票日期

  • 发票到期日

  • 与发票关联的一个或多个项目行(数组)

为了在 GraphQL 中实现相同的逻辑,我们必须从突变的角度来考虑。突变具有以下特征:

  • 这不是一个查询

  • 这需要争论

我们已经看到了带有参数的 GraphQL 查询的外观。一个突变并没有太大的不同。考虑到创建新发票的需求,我们希望能够设计清单 11-15 中所示的变体。

mutation {
 invoiceCreate(invoice: {
   user: 1
   date: "2021-02-15"
   dueDate: "2021-02-15"
   state: PAID
   items: [{
     quantity: 1
     description: "Django backend"
     price: 6000.00
     taxed: false
   },
   {
     quantity: 1
     description: "React frontend"
     price: 8000.00
     taxed: false
   }]
 }) {
   user { id }
   date
   state
 }
}

Listing 11-15The Mutation Request for Creating a New Invoice

我们可以从 invoice 参数中看出,有效载荷不再是简单的标量,而是一个复杂的对象。这样的输入对象被称为输入类型,即强类型参数。我们可以在变异的签名中单独传递这些参数,但更好的是,我们可以利用 GraphQL 类型系统将单个对象的形状定义为一个参数。作为该查询的返回值,我们要求服务器使用以下数据进行响应:

  • 连接到新发票的用户 ID

  • 发票的日期和状态

在 GraphQL 中添加变异的过程与添加查询没有什么不同:

  • 首先,我们在模式中定义突变及其输入

  • 然后我们创建一个解析器来处理副作用

在 GraphQL 中,突变在Mutation类型下声明。为了创建我们的第一个变异,添加清单 11-16 到billing/schema.graphql中的代码。

type Mutation {
   invoiceCreate(invoice: InvoiceInput!): Invoice!
}

Listing 11-16billing/schema.graphql - The Mutation for Creating a New Invoice

invoiceCreate变异接受一个名为invoice的参数,类型为InvoiceInput,不可为空。作为交换,它返回一个不可空的Invoice。我们现在需要定义输入类型。它们应该是什么样子?首先,它们应该包含创建发票所需的所有字段。我们也不要忘记项目行是一个项目数组。在billing/schema.graphql中,我们创建了两种输入类型,如清单 11-17 所示。

input ItemLineInput {
   quantity: Int!
   description: String!
   price: Float!
   taxed: Boolean!
}

input InvoiceInput {
   user: ID!
   date: String!
   dueDate: String!
   state: InvoiceState
   items: [ItemLineInput!]!
}

type Mutation {
   invoiceCreate(invoice: InvoiceInput!): Invoice!
}

Listing 11-17billing/schema.graphql - Input Types for the Mutation

我们现在有:

  • InvoiceInput:用作变异参数类型的输入类型。它有一个ItemLineInputitems数组。

  • ItemLineInput:表示单项的输入类型,作为输入类型。

这些输入类型将反映在模式和文档中。有了模式,我们现在可以连接相应的解析器了。

Note

在对billing/schema.graphql进行更改之后,您应该重启 Django development server,否则更改将不会生效。

为突变添加解析器

有了突变的定义,我们现在可以添加相应的解析器了。

这个解析器将接触数据库以保存新的发票。为了实现这一点,我们需要在我们的模式中引入更多的代码,特别是:

  • MutationType:创建突变根类型

  • InvoiceItemLine:Django 模型

清单 11-18 显示了我们需要在billing/schema.py中进行的更改。

...
from ariadne import ObjectType, MutationType
...
from billing.models import Invoice, ItemLine

...

mutation = MutationType()

@mutation.field("invoiceCreate")
def resolve_invoice_create(obj, info, invoice):
   user_id = invoice.pop("user")
   items = invoice.pop("items")

   invoice = Invoice.objects.create(user_id=user_id, **invoice)
   for item in items:
       ItemLine.objects.create(invoice=invoice, **item)
   return invoice

schema = make_executable_schema(type_defs, query, mutation)

Listing 11-18billing/schema.py - Adding a Resolver to Fulfill the Mutation

这段代码中发生了很多事情。让我们来分解一下:

  • 我们使用MutationType()在 Ariadne 中创建新的突变根类型

  • 我们修饰突变解析器,使其映射到模式中定义的字段

  • 在解析器中,我们用 ORM 创建一个新的发票

  • 我们把突变和模式联系起来

Django 想要一个用户实例来创建新的发票,但是我们从 GraphQL 请求中得到的只是用户的 ID。这就是为什么我们从有效载荷中移除了user来将它作为user_id传递给Invoice.objects.create()。至于接下来的步骤,逻辑类似于我们在第五章中在串行化器中所做的。有了这些额外的代码,我们现在可以将清单 11-19 中所示的变异发送到 GraphQL。

mutation {
 invoiceCreate(invoice: {
   user: 1
   date: "2021-02-15"
   dueDate: "2021-02-15"
   state: PAID
   items: [{
     quantity: 1
     description: "Django backend"
     price: 6000.00
     taxed: false
   },
   {
     quantity: 1
     description: "React frontend"
     price: 8000.00
     taxed: false
   }]
 }) {
   user { id }
   date
   state
 }
}

Listing 11-19The Mutation Request for Creating a New Invoice

在 GraphQL Playground 中发出变异,您应该会看到以下错误:

Invoice() got an unexpected keyword argument 'dueDate'

这来自 Django 的 ORM 层。在我们的变异中,我们发出一个名为dueDate的字段,这是 camel case 中 GraphQL/JS 世界的惯例。然而,Django 期望due_date,正如它在模型中定义的那样。为了修复这种不匹配,我们可以使用阿里阿德涅的convert_kwargs_to_snake_case。打开billing/schema.py并应用清单 11-20 中所示的更改。

...
from ariadne.utils import convert_kwargs_to_snake_case
...

...
@mutation.field("invoiceCreate")
@convert_kwargs_to_snake_case
def resolve_invoice_create(obj, info, invoice):
...

Listing 11-20billing/schema.py - Converting from Camel Case to Snake Case

这里,我们用转换器工具来修饰我们的变异解析器。如果一切都在正确的位置,服务器现在应该返回以下响应:

{
 "data": {
   "invoiceCreate": {
     "user": {
       "id": "1",
       "email": "juliana@acme.io"
     },
     "date": "2021-02-15",
     "state": "PAID"
   }
 }
}

有了查询和变异,我们现在就可以将一个 React 前端连接到我们的 GraphQL 后端了。但是首先,我要说一下 GraphQL 客户端。

GraphQL 客户端简介

我们看到,就网络层而言,GraphQL 似乎不需要神秘的工具。

GraphQL 客户机和它的服务器之间的对话通过 HTTP 进行,带有POST请求。我们甚至可以用curl调用 GraphQL 服务。这意味着,在浏览器中,我们可以使用fetch、axios 甚至XMLHttpRequest对 GraphQL API 发出请求。在现实中,这对小应用来说可能很好,但迟早,在现实世界中,我们需要的不仅仅是fetch。具体来说,对于几乎每个数据提取层,我们都需要考虑某种缓存。由于 GraphQL 的工作方式,我们可以只请求数据的一个子集,但这并不排除节省往返服务器的需要。在接下来的部分中,我们将使用最流行的 JavaScript graph QL 客户端之一:Apollo 客户端。对于在前端使用 GraphQL 的开发人员来说,这个工具抽象出了性能优化的所有平凡细节。

构建 React 前端

在第六章,我们构建了一个用于创建发票的 Vue.js app。

该应用有一个<form>,它又包含一个<select>和一些用于插入发票详细信息的字段。对于这个 React 应用,我们构建了相同的结构,这次将每个子组件拆分到自己的文件中。在本章中,我们将使用查询部分。在第十二章中,我们看到了如何处理突变。首先,我们初始化一个 React 项目。我们移动到billing文件夹,然后启动create-react-app。要创建 React 项目,请运行以下命令:

npx create-react-app react_spa --template typescript

这将在billing/react_spa中创建项目。一旦项目就位,在新的终端移入文件夹:

cd react_spa

一旦 GraphQL 层就位,我们将从这个文件夹启动 React 应用。

Note

对于反应部分,我们在decoupled_dj/billing/react_spa中工作。必须从该路径开始,在适当的子文件夹中创建或更改每个建议的文件。

Apollo 客户端入门

首先,我们需要在项目中安装 Apollo 客户机。

为此,请运行以下命令:

npm i @apollo/client

一旦安装完成,打开src/App.tsx,清除所有的样板文件,并用清单 11-21 中所示的代码填充文件。

import {
 ApolloClient,
 InMemoryCache,
 gql,
} from "@apollo/client";

const client = new ApolloClient({
 uri: "http://127.0.0.1:8000/billing/graphql/",
 cache: new InMemoryCache(),
});

Listing 11-21src/App.tsx - Initializing Apollo Client

这里,我们通过提供 GraphQL 服务的 URL 来初始化客户端。ApolloClient构造函数至少接受这两个选项:

  • uri:graph QL 服务地址

  • cache:客户端的缓存策略

这里我们使用InMemoryCache,它是 Apollo 客户端中包含的默认缓存包。一旦我们有了一个客户端实例,要向服务器发送请求,我们可以使用:

  • client.query()发送查询

  • client.mutate()送突变

为了配合 React 使用,Apollo 还提供了一套方便的挂钩。在接下来的几节中,我们将创建三个 React 组件,并了解如何使用客户端方法,以及如何使用钩子。

创建选择组件

这个<select>是我们形态的一部分,会从外部接受道具。它应该为数据库中的每个用户呈现一个<option>元素。在src/Select.tsx中,我们创建了清单 11-22 中所示的组件。

import React from "react";

type Props = {
 id: string;
 name: string;
 options: Array<{
   id: string;
   email: string;
 }>;
};

const Select = ({ id, name, options }: Props) => {
 return (
   <select id={id} name={name} required={true}>
     <option value="">---</option>
     {options.map((option) => {
       return (
         <option value={option.id}>{option.email}</option>
       );
     })}
   </select>
 );
};

export default Select;

Listing 11-22src/Select.tsx - Select Component with TypeScript Definitions

该组件从外部接受一个选项列表以呈现给用户。在添加 GraphQL 之前,在 Vue.js 中,我们从 REST API 获取这些数据。相反,在这个应用中,我们让一个根组件处理数据获取,这次是从 GraphQL 服务,并将数据传递给<select>。现在让我们构建表单。

创建表单组件

表单组件相当简单,因为它接受一个处理submit事件的函数,以及一个或多个子组件。在src/Form.tsx中,我们创建了清单 11-23 中所示的组件。

import React from "react";

type Props = {
 children: React.ReactNode;
 handleSubmit(
   event: React.FormEvent<HTMLFormElement>
 ): void;
};

const Form = ({ children, handleSubmit }: Props) => {
 return <form onSubmit={handleSubmit}>{children}</form>;
};

export default Form;

Listing 11-23src/Form.tsx - Form Component with TypeScript Definitions

有了<form><select>之后,我们现在可以连接包含两者的根组件。

创建根组件并进行查询

每个 React 应用必须有一个根组件,负责渲染整个应用的外壳。为了简单起见,我们将在src/App.tsx中创建根组件。在我们的根组件中,我们需要:

  • 使用以下查询向 GraphQL 服务查询客户端列表

  • 处理提交事件,有一个突变

这里的想法是,当应用挂载时,我们使用useEffect()client.query()查询 GraphQL API。在 GraphQL Playground 中,我们使用以下查询来获取客户端列表:

query {
 getClients {
   id
   email
 }
}

在我们的 React 组件中,我们将使用相同的查询来获取客户端。这也是我们<select>的数据来源。组装查询时要记住的唯一一件事是,这是一个匿名查询,而在我们的 React 组件中,我们需要使用稍微不同的形式,作为命名查询。让我们在src/App.tsx中创建组件,如清单 11-24 所示。

import React, { useEffect, useState } from "react";
import {
 ApolloClient,
 InMemoryCache,
 gql,
} from "@apollo/client";
import Form from "./Form";
import Select from "./Select";

const client = new ApolloClient({
 uri: "http://127.0.0.1:8000/billing/graphql/",
 cache: new InMemoryCache(),
});

const App = () => {
 const [options, setOptions] = useState([
   { id: "", email: "" },
 ]);

 const handleSubmit = (
   event: React.FormEvent<HTMLFormElement>
 ) => {
   event.preventDefault();
   // client.mutate()
 };

 const GET_CLIENTS = gql`
   query getClients {
     getClients {
       id
       email
     }
   }
 `;

 useEffect(() => {
   client
     .query({
       query: GET_CLIENTS,
     })
     .then((queryResult) => {
       setOptions(queryResult.data.getClients);
     })
     .catch((error) => {
       console.log(error);
     });
 }, []);

 return (
   <Form handleSubmit={handleSubmit}>
     <Select id="user" name="user" options={options} />
   </Form>
 );
};

export default App;

Listing 11-24src/App.tsx - React Component for Fetching Data

让我们详细解释一下这段代码的内容:

  • 我们为阿波罗客户端保留逻辑

  • 我们使用useState()来初始化组件的状态

  • 该状态包含了作为道具传递的<select>的选项列表

  • 我们定义了一个处理submit事件的最小方法

  • 我们使用useEffect()从 GraphQL API 获取数据

  • 我们将FormSelect呈现给用户

阿波罗部分也值得一读。首先,查询如清单 11-25 所示。

...
 const GET_CLIENTS = gql`
   query getClients {
     getClients {
       id
       email
     }
   }
 `;
...

Listing 11-25Building the Query

这里我们使用gql将查询包装在一个模板文字标签中。这将生成一个 GraphQL 抽象语法树,供实际的 GraphQL 客户端使用。接下来,让我们检查发送查询的逻辑:

...
   client
     .query({
       query: GET_CLIENTS,
     })
     .then((queryResult) => {
       setOptions(queryResult.data.getClients);
     })
     .catch((error) => {
       console.log(error);
     });
...

在这个逻辑中,我们通过为一个对象提供一个query属性来调用client.query(),这个属性被分配给前面的查询。client.query()回报诺言。这意味着我们可以使用then()来消费结果,使用catch()来处理错误。在then()中,我们访问查询结果,并使用来自组件状态的setOptions来保存结果。在data.getClients上可以访问查询结果,这恰好是我们查询的名称。这看起来有点冗长。事实上,Apollo 提供了一个useQuery()钩子来减少样板文件,我们马上就会看到。保存好所有的东西后,为了进行测试,我们应该运行 Django,像往常一样在终端中运行decoupled-dj:

python manage.py runserver

在另一个终端中,从/billing/react_spa我们可以运行 React 应用:

npm start

这将在http://localhost:3000/启动 React。在用户界面中,我们应该能够看到一个select元素,其中每个选项呈现了每个客户的 ID 和电子邮件,如图 11-3 所示。

img/505838_1_En_11_Fig3_HTML.jpg

图 11-3

选择组件从 GraphQL API 接收数据

这可能看起来不像是一个巨大的成就,与用 axios 或fetch获取数据没有太大区别。在下一节中,我们将看到如何用 Apollo 钩子来清理 React 的逻辑。

使用阿波罗挂钩进行反应

Apollo 客户端并不劝阻使用client.query()

然而,在 React 应用中,开发人员可能希望使用 Apollo 钩子来保持代码库的一致性,就像我们习惯于在组件中使用useState()useEffect()一样。Apollo Client 包含一组钩子,使得在 React 中使用 GraphQL 变得轻而易举。要进行查询,我们可以使用useQuery()钩子来代替client.query()。然而,在我们的应用中,这需要一点重新安排。首先,我们需要用ApolloProvider包装整个应用。对于那些熟悉 Redux 或 React 上下文 API 的人来说,这是 Redux Provider或 React 上下文对等物所公开的相同概念。在我们的应用中,我们首先需要在src/index.tsx中移动 Apollo 客户端实例化。在同一个文件中,我们还用提供者包装了整个应用。清单 11-26 展示了我们需要做出的改变。

import React from "react";
import ReactDOM from "react-dom";
import {
 ApolloClient,
 InMemoryCache,
 ApolloProvider,
} from "@apollo/client";
import App from "./App";

const client = new ApolloClient({
 uri: "http://127.0.0.1:8000/billing/graphql/",
 cache: new InMemoryCache(),
});

ReactDOM.render(
 <React.StrictMode>
   <ApolloProvider client={client}>
     <App />
   </ApolloProvider>
 </React.StrictMode>,
 document.getElementById("root")
);

Listing 11-26src/index.tsx - The App Shell

现在,在src/App.tsx中,我们只从 Apollo 客户端导入了gqluseQuery,另外,我们通过将查询移出组件来对其进行一些安排。在组件体中,我们使用useQuery(),如清单 11-27 所示。

import React from "react";
import { gql, useQuery } from "@apollo/client";
import Form from "./Form";
import Select from "./Select";

const GET_CLIENTS = gql`
 query getClients {
   getClients {
     id
     email
   }
 }
`;

const App = () => {
 const { loading, data } = useQuery(GET_CLIENTS);

 const handleSubmit = (
   event: React.FormEvent<HTMLFormElement>
 ) => {
   event.preventDefault();
   // client.mutate()
 };

 return loading ? (
   <p>Loading ...</p>
 ) : (
   <Form handleSubmit={handleSubmit}>
     <Select
       id="user"
       name="user"
       options={data.getClients}
     />
   </Form>
 );
};

export default App;

Listing 11-27src/App.tsx - GraphQL Query with Apollo Hook

FormSelect都可以保持不变。我们还可以注意到,useQuery()将我们的查询作为一个参数,并免费返回一个loading布尔值(便于条件渲染)和一个包含查询结果的data对象。这比client.query()干净多了。如果我们再次运行这个项目,一切应该仍然像预期的那样工作,在 UI 中呈现一个<select>。通过这一改变,我们现在可以充分利用钩子的声明式风格,与 GraphQL 配合使用。

Note

这是提交到目前为止所做的更改并将工作推送到 Git repo 的好时机。你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_11_graphql_ariadne 找到本章的源代码。

摘要

本章用 GraphQL API 扩充了第六章的计费应用。您还看到了如何将 React 连接到 GraphQL 后端。在此过程中,您了解到:

  • GraphQL 构建模块

  • 使用 Ariadne 向 Django 项目添加 GraphQL

  • 将 React 连接到 GraphQL 后端

在下一章中,我们将继续在 Django 中探索 GraphQL,使用 Strawberry,并且我们还在混合中添加了一些变化。

额外资源

十二、Django 的 GraphQL 和 Strawberry

本章涵盖:

  • 带 Strawberry 的代码优先 GraphQL

  • 异步 Django 和 GraphQL

  • Apollo 客户端的变化

在前一章中,我们介绍了使用 Ariadne 的 GraphQL APIs 的模式优先的概念。

我们研究了查询和 Apollo 客户端。在这一章中,我们切换到代码优先的方法,用 Strawberry 构建我们的 GraphQL API。在这个过程中,我们在前端添加了变异,并学习了如何在 Django 中使用异步代码。

Note

本章的其余部分假设您在 repo root decoupled-dj中,Python 虚拟环境是活动的,并且DJANGO_SETTINGS_MODULE被配置为decoupled_dj.settings.development

Django Strawberry 入门

一开始,GraphQL 主要针对 JavaScript。

GraphQL 服务器的大多数早期实现都是为 Node.js 编写的,这并不是巧合。随着时间的推移,大多数编程社区对这种新的数据查询范式产生了兴趣,现在我们已经有了大多数语言的 GraphQL 实现。在 Python 领域,我们探索了 Ariadne,我们提到了石墨烯。Strawberry 和这些库有什么不同?首先,Strawberry 大量使用 Python 数据类。Python 中的 Dataclasses 是用属性和可选逻辑声明简洁类的简单方法。以下示例显示了一个 Python 数据类:

class User:
   name: str
   email: str

在这个例子中,我们声明了一个没有行为的 Python 类,但是有两个属性,nameemail。这些属性也是强类型的;也就是说,他们能够强制执行他们可以持有的类型,在本例中是字符串。Strawberry 大量使用 Python 类型提示。类型提示是一个可选的 Python 特性,可以提高我们代码的健壮性。Python 很像 JavaScript,是一种不强制静态类型的动态语言。通过类型提示,我们可以在 Python 代码中添加一个类型层,在生产中发布代码之前,可以使用一个名为 MyPy 的工具对其进行检查。这可以捕捉可能进入运行时环境的讨厌的 bug。此外,静态类型检查改善了开发人员的体验。在 Strawberry 中,我们将使用 dataclasses 来定义我们的 GraphQL 类型和类型提示。该练习了!

安装 Strawberry

首先,我们在 Django 项目中安装 Strawberry:

pip install strawberry-graphql

安装完成后,我们更新requirements/base.txt以包含新的依赖项。接下来,我们在decoupled_dj/settings/base.py中启用 Strawberry,如清单 12-1 所示。

INSTALLED_APPS = [
      ...
      "strawberry.django",
]

Listing 12-1decoupled_dj/settings/base.py - Enabling Strawberry in INSTALLED_APPS

启用 Strawberry 后,我们可以从模式优先到代码优先来重构模式。

在 Strawberry 中设计 GraphQL 模式

在前一章中,我们在一个.graphql文件中创建了一个 GraphQL 模式。

让我们回顾一下目前我们所掌握的情况。清单 12-2 显示了我们在第十一章中组装的 GraphQL 模式。

enum InvoiceState {
   PAID
   UNPAID
   CANCELLED
}

type User {
   id: ID
   name: String
   email: String
}

type Invoice {
   user: User
   date: String
   dueDate: String
   state: InvoiceState
   items: [ItemLine]
}

type ItemLine {
   quantity: Int
   description: String
   price: Float
   taxed: Boolean
}

type Query {
   getClients: [User]
   getClient(id: ID!): User
}

input ItemLineInput {
   quantity: Int!
   description: String!
   price: Float!
   taxed: Boolean!
}

input InvoiceInput {
   user: ID!
   date: String!
   dueDate: String!
   state: InvoiceState
   items: [ItemLineInput!]!
}

type Mutation {
   invoiceCreate(invoice: InvoiceInput!): Invoice!
}

Listing 12-2billing/schema.graphql - The Original GraphQL Schema

在这个模式中,我们使用了该语言提供的大多数 GraphQL 标量类型,加上我们的自定义类型和输入类型定义。我们还创建了两个查询和一个变异。为了理解 Strawberry 所提供的功能,让我们将模式中的每个元素从纯文本模式移植到 Python 代码中。

Strawberry 的类型和枚举

首先,我们从 GraphQL 模式的基本类型开始。

我们需要声明UserInvoiceItemLine。要创建模式,打开billing/schema.py,清除我们在第十一章中创建的所有代码,并导入清单 12-3 中显示的模块。

import strawberry
import datetime
import decimal

from typing import List

Listing 12-3billing/schema.py - Initial Imports

typing是主要的 Python 模块,从中我们可以仔细阅读最常见的类型声明。接下来是strawberry本身。我们还需要decimal模块和精华datetime。接下来,我们准备创建我们的第一个类型。清单 12-4 显示了 Strawberry 中的三种 GraphQL 类型。

import strawberry
import datetime
import decimal

from typing import List

@strawberry.type
class User:
   id: strawberry.ID
   name: str
   email: str

@strawberry.type
class Invoice:
   user: User
   date: datetime.date
   due_date: datetime.date
   state: InvoiceState
   items: List["ItemLine"]

@strawberry.type
class ItemLine:
   quantity: int
   description: str
   price: decimal.Decimal
   taxed: bool

Listing 12-4billing/schema.py - First Types in Strawberry

对于刚接触 Python 类型的人来说,这里有很多东西需要解释一下。谢天谢地,Python 的表达能力足够强,不会让事情变得过于复杂。让我们从头开始。

为了在 Strawberry 中声明一个新的 GraphQL 类型,我们使用了@strawberry.type decorator,它位于我们的 dataclasses 之上。接下来,将每个类型声明为一个 dataclass,每个类型包含一组属性。在第 1 和 11 章中,我们看到了 GraphQL 标量类型。在 Strawberry 中,除了strawberry.ID,没有什么特别的东西可以描述这些标量。正如您在清单 12-4 中看到的,大多数标量类型被表示为 Python 原语:strintbool。唯一的例外是datedue_date的类型,我们在最初的 GraphQL 模式中将其声明为字符串。因为 Strawberry 中的类型是数据类,而数据类“只是”Python 代码,而不是日期字符串,所以我们现在可以使用datetime.date对象。这是我们在第十一章中未解决的问题之一,现在已经解决了。

Note

你可能想知道这里的due_date和前一章的dueDate有什么关系。在最初的 GraphQL 模式中,我们在 camel 案例中使用了dueDate。在到达 Django ORM 之前,Ariadne 将该语法转换为 snake case。现在我们在 GraphQL 模式中再次使用 snake case。为什么呢?作为 Python 代码,惯例是对较长的变量和函数名使用 snake case。但是这一次,转换以相反的方式发生:在 GraphQL 文档模式中,Strawberry 将字段显示为 camel case!

接下来,请注意如何通过将 dataclass 属性与相应的实体相关联来描述关系,比如在Invoice中将User dataclass 分配给user。还要注意来自 Python typings 的List类型将ItemLine关联到items。之前,我们使用 GraphQL 中的Float标量来表示每个ItemLine的价格。在 Python 中,我们可以使用更合适的decimal.Decimal。即使从这样一个简单的清单中,我们也可以推断出用 Python 代码编写 GraphQL 模式的 Strawberry 方法带来了很多好处,包括类型安全性、灵活性和对标量类型的更好处理。

在最初的 GraphQL 模式中,我们有一个与Invoice相关联的enum类型,它指定发票是已支付、未支付还是已取消。在新的模式中,我们已经有了Invoice,所以这是一个添加枚举的问题。在 Strawberry 中,我们可以使用普通的 Python 枚举来声明相应的 GraphQL 类型。在模式文件中,添加枚举,如清单 12-5 (这应该在User之前)。

...
from enum import Enum

@strawberry.enum
class InvoiceState(Enum):
   PAID = "PAID"
   UNPAID = "UNPAID"
   CANCELLED = "CANCELLED"
...

Listing 12-5billing/schema.py - Enum Type in Strawberry

这与我们在 Django 的Invoice模型的选择非常相似。只要有一点创造力,就可以在 Django 模型中重用这个 Strawberry 枚举(或者反过来)。有了 enum,我们几乎可以开始测试了。让我们在接下来的部分中添加解析器和查询。

使用解析器(再次)

我们已经知道 GraphQL 模式需要解析器来返回数据。

让我们添加两个解析器到我们的模式中,这两个解析器几乎是直接从第十一章复制过来的(参见清单 12-6 )。

...
from users.models import User as UserModel
...

def resolve_clients():
   return UserModel.objects.all()

def resolve_client(id: strawberry.ID):
   return UserModel.objects.get(id=id)

Listing 12-6billing/schema.py - Adding Resolvers to the Schema

为了避免与这里的User GraphQL 类型冲突,我们将用户模型导入为UserModel。接下来,我们声明解析器来完成最初的 GraphQL 查询,即getClientgetClients。请注意我们如何将一个id作为参数传递给第二个解析器,以便像在上一章中那样通过 ID 获取单个用户。有了这些解析器,我们可以添加一个Query类型,最后在下一节中连接 GraphQL 端点。

Strawberry 中的查询和连接 GraphQL 端点

有了基本类型和解析器,我们可以为我们的 API 创建一个代码优先的Query类型。

将清单 12-7 中所示的代码添加到模式文件中。

@strawberry.type
class Query:
   get_clients: List[User] = strawberry.field(resolver=resolve_clients)
   get_client: User = strawberry.field(resolver=resolve_client)
schema = strawberry.Schema(query=Query)

Listing 12-7billing/schema.py - Adding a Root Query Type

这里我们告诉 Strawberry graph QL 有一个带有两个字段的Query类型。让我们详细看看这些字段:

  • get_clients返回一列User并连接到名为resolve_clients的解析器

  • get_client返回单个User并连接到名为resolve_client的解析器

两个分解器都用strawberry.field()包裹。注意,在 GraphQL 文档中,这两个查询都将被转换为 camel case,即使它们在我们的代码中被声明为 snake case。在最后一行中,我们将模式加载到 Strawberry 中,因此它被提取出来并提供给用户。值得注意的是,Strawberry 中的解析器不必与Query数据类本身断开连接。事实上,我们可以在Query中将它们声明为方法。我们将这两个解析器留在数据类之外,但是我们稍后将看到作为Mutation数据类的方法的变异。

有了这个逻辑,我们可以在billing/urls.py中将 GraphQL 层连接到 Django URL 系统。从 Ariadne 中删除 GraphQL 视图。这一次,我们使用来自 Strawberry 的异步 GraphQL 视图,而不是常规视图,如清单 12-8 所示。

...
from strawberry.django.views import AsyncGraphQLView
...

app_name = "billing"

urlpatterns = [
   ...
   path("graphql/",
        AsyncGraphQLView.as_view(schema=schema),
        name="graphql"
        ),
]

Listing 12-8billing/urls.py - Wiring Up the GraphQL Endpoint

通过异步运行 GraphQL API,我们有了一个新的可能性的世界,但也有许多新的东西要考虑,我们马上就会看到。我们将在接下来的章节中探索一个例子。记住,要异步运行 Django,我们需要一个像 Uvicorn 这样支持 ASG 的服务器。我们在第五章安装了这个包,但是回顾一下,你可以用下面的命令安装 Uvicorn:

pip install uvicorn

接下来,导出DJANGO_SETTINGS_MODULE环境变量,如果您还没有这样做的话:

export DJANGO_SETTINGS_MODULE=decoupled_dj.settings.development

最后,使用以下命令运行服务器:

uvicorn decoupled_dj.asgi:application --reload

标志确保 Uvicorn 在文件改变时重新加载。如果一切顺利,您应该会看到以下输出:

INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

在接下来的部分中,我们将在 Uvicorn 下运行 Django。现在我们可以前往http://127.0.0.1:8000/billing/graphql/。这将打开 GraphQL,一个探索 GraphQL API 的平台。在操场上,我们可以发出查询和突变,探索模式和集成文档,就像我们对 Ariadne 所做的那样。现在您已经有了大致的了解,您可以用输入类型和突变来完成模式。

Strawberry 的输入类型和突变

我们看到 GraphQL 中的输入类型基本上是突变的参数。

为了在 Strawberry 中定义输入类型,我们仍然创建一个 dataclass,但是这一次我们在它上面使用了@strawberry.input装饰器。让我们为ItemLineInputInvoiceInput创建两个输入类型(这段代码可以放在Query类型之后);见清单 12-9 。

...
@strawberry.input
class ItemLineInput:
   quantity: int
   description: str
   price: decimal.Decimal
   taxed: bool

@strawberry.input
class InvoiceInput:
   user: strawberry.ID
   date: datetime.date
   due_date: datetime.date
   state: InvoiceState
   items: List[ItemLineInput]

Listing 12-9billing/schema.py - Adding Input Types to the Schema

这里我们有数据类,它们具有输入类型的适当属性。作为糖衣,...哎呀,Strawberry,在蛋糕上,让我们也添加一个突变(见清单 12-10 )。

...
import dataclasses
...
from billing.models import Invoice as InvoiceModel
from billing.models import ItemLine as ItemLineModel
...
@strawberry.type
class Mutation:
   @strawberry.mutation
   def create_invoice(self, invoice: InvoiceInput) -> Invoice:
       _invoice = dataclasses.asdict(invoice)
       user_id = _invoice.pop("user")
       items = _invoice.pop("items")
       state = _invoice.pop("state")

       new_invoice = InvoiceModel.objects.create(
           user_id=user_id, state=state.value, **_invoice
       )
       for item in items:
           ItemLineModel.objects.create(invoice=_invoice, **item)
       return new_invoice

schema = strawberry.Schema(query=Query, mutation=Mutation)

Listing 12-10billing/schema.py - Adding a Mutation to the Schema

这段代码需要一点解释。首先,我们再次导入 Django 模型,这一次通过别名化它们来避免与数据类冲突。接下来,我们定义一个Mutation和其中的一个方法。名为create_invoice()的方法将InvoiceInput输入类型作为参数,并用@strawberry.mutation修饰。在方法内部,我们将 dataclass 输入类型转换为字典。这很重要,因为突变参数是一个数据类,而不是一个字典。这样,我们可以弹出我们需要的键,就像我们对 Ariadne 所做的那样。在突变中,我们还弹出了state,后来作为state.value传递到InvoiceModel.objects.create()。在撰写本文时,Strawberry 没有自动将枚举键转换为字符串,所以我们需要做一些数据钻取。最后,注意这个变异的返回值的类型注释,Invoice。在文件的最后,我们还将突变数据类加载到模式中。

变异和输入类型现在完成了我们的 GraphQL 模式。在这个阶段,我们可以使用 GraphiQL Playground 发送一个invoiceCreate变异,但是我们将在 React 前端实现变异,而不是手动尝试。但是首先,让我们看看用 Django 异步运行 Strawberry 的含义。

与 Django ORM 异步工作

在设置好一切之后,您可能已经注意到,在 GraphiQL 中发送一个简单的查询,一切都会变得一团糟。

即使发出一个简单的查询,Django 也会响应以下错误:

You cannot call this from an async context - use a thread or sync_to_async.

这个错误有点神秘,但是它来自 ORM 层。在撰写本文时,Django 的 ORM 还不支持异步。这意味着我们不能在异步运行 Django 时简单地启动 ORM 查询。为了解决这个问题,我们需要用 ASGI 的一个名为sync_to_async的异步适配器来包装 ORM 交互。为了便于理解,我们首先将实际的 ORM 查询转移到单独的函数中。然后,我们用sync_to_async包装这些函数。清单 12-11 显示了所需的变更。

...
from asgiref.sync import sync_to_async
...

def _get_all_clients():
   return list(UserModel.objects.all())

async def resolve_clients():
   return await sync_to_async(_get_all_clients)()

def _get_client(id):
   return UserModel.objects.get(id=id)

async def resolve_client(id: strawberry.ID):
   return await sync_to_async(_get_client)(id)

Listing 12-11billing/schema.py - Converting ORM Queries to Work Asynchronously

让我们看看这是怎么回事。首先,我们将 ORM 逻辑转移到两个常规函数中。在第一个函数_get_all_clients()中,我们用.all()从数据库中获取所有客户端。我们还强制 Django 通过将查询集转换成一个带有list()的列表来计算查询集。有必要在异步上下文中评估查询,因为 Django querysets 在默认情况下是懒惰的。在第二个函数_get_client()中,我们简单地从数据库中获取一个用户。然后这两个函数在两个异步函数中被调用,包装在sync_to_async()中。这种机制将使 ORM 代码在 ASGI 下工作。

解析器不是唯一需要异步包装器的部分。虽然在这个阶段,我们不希望任何人猛烈攻击我们的 GraphQL 变种,但是保存新发票的 ORM 代码也需要包装。同样,我们可以取出 ORM 相关的代码来分离函数,然后用sync_to_async包装这些函数,如清单 12-12 所示。

...
from asgiref.sync import sync_to_async
...
def _create_invoice(user_id, state, invoice):
   return InvoiceModel.objects.create(user_id=user_id, state=state.value, **invoice)

def _create_itemlines(invoice, item):
   ItemLineModel.objects.create(invoice=invoice, **item)

@strawberry.type
class Mutation:
   @strawberry.mutation
   async def create_invoice(self, invoice: InvoiceInput) -> Invoice:
       _invoice = dataclasses.asdict(invoice)
       user_id = _invoice.pop("user")
       items = _invoice.pop("items")
       state = _invoice.pop("state")

       new_invoice = await sync_to_async(_create_invoice)(user_id, state, _invoice)
       for item in items:
           await sync_to_async(_create_itemlines)(new_invoice, item)
       return new_invoice

Listing 12-12billing/schema.py - Converting ORM Queries to Work Asynchronously

这看起来像是做了很多 Django 提供的现成的代码,即 SQL 查询,但是这是为了异步运行 Django 所付出的代价。在未来,我们希望对 ORM 层有更好的异步支持。现在,有了这些改变,我们可以异步地并行运行 Strawberry 和 Django 了。我们现在可以移到前端,用 Apollo 客户机实现突变。

又在前端工作了

在第十一章,我们开始开发一个 React/TypeScript 前端,它充当我们的 GraphQL API 的客户端。

到目前为止,我们在前端为一个<select>组件实现了一个简单的查询。首先,我们使用 Apollo client.query(),这是一种低级的查询方法。然后,我们重构使用了useQuery()钩子。在接下来的章节中,我们用 Apollo 客户端和useMutation()处理前端的突变。

Note

对于反应部分,我们在decoupled_dj/billing/react_spa中工作。必须从该路径开始,在适当的子文件夹中创建或更改每个建议的文件。

使用变异创建发票

我们离开前一章,使用清单 12-13 中显示的App组件。

import React from "react";
import { gql, useQuery } from "@apollo/client";
import Form from "./Form";
import Select from "./Select";

const GET_CLIENTS = gql`
 query getClients {
   getClients {
     id
     email
   }
 }
`;

const App = () => {
 const { loading, data } = useQuery(GET_CLIENTS);

 const handleSubmit = (
   event: React.FormEvent<HTMLFormElement>
 ) => {
   event.preventDefault();
   // client.mutate()
 };

 return loading ? (
   <p>Loading ...</p>
 ) : (
   <Form handleSubmit={handleSubmit}>
     <Select
       id="user"
       name="user"
       options={data.getClients}
     />
   </Form>
 );
};

export default App;

Listing 12-13src/App.tsx - GraphQL Query with Apollo

这个组件使用一个查询来填充装载在 DOM 中的<select>。现在是实施突变的时候了。到目前为止,在 Ariadne 中,我们通过在 GraphQL Playground 中提供突变负载来发送突变。这一次,前端发生了一些变化:我们需要从 Apollo 客户端使用useMutation()。首先,我们导入新的钩子,如清单 12-14 所示。

...
import { gql, useQuery, useMutation } from "@apollo/client";
...

Listing 12-14src/App.tsx - Importing useMutation

接下来,就在GET_CLIENTS查询之后,我们声明一个名为CREATE_INVOICE的变异,如清单 12-15 所示。

...
const CREATE_INVOICE = gql`
 mutation createInvoice($invoice: InvoiceInput!) {
   createInvoice(invoice: $invoice) {
     date
     state
   }
 }
`;
...

Listing 12-15src/App.tsx - Declaring a Mutation

这种变异看起来有点像函数,因为它接受一个参数并向调用者返回一些数据。但是这种情况下的参数是输入类型。现在,在App中,我们使用新的钩子。useMutation()的用法让人想起 React 中的useState():我们可以从钩子中数组化析构一个函数。清单 12-16 展示了我们组件中的变异钩子。

...
const App = () => {
 const { loading, data } = useQuery(GET_CLIENTS);
 const [createInvoice] = useMutation(CREATE_INVOICE);
...

Listing 12-16src/App.tsx - Using the useMutation Hook

另外,我们还可以用两个属性来析构一个对象:errorloading。与查询一样,这些将在出现错误时提供信息,并在变异期间提供加载状态以有条件地呈现 UI。为了避免与查询中的loading冲突,我们给变异加载器分配了一个新名称。清单 12-17 显示了这些变化。

...
const App = () => {
  const { loading, data } = useQuery(GET_CLIENTS);
  const [
      createInvoice,
      { error, loading: mutationLoading },
  ] = useMutation(CREATE_INVOICE);
...

Listing 12-17src/App.tsx - Using the useMutation Hook with Loading and Error

既然我们已经遇到了突变,让我们看看如何在前端使用它们。从useMutation()钩子中,我们析构了createInvoice(),现在我们可以调用这个函数来响应一些用户交互。在这种情况下,我们的组件中已经有了一个handleSubmit(),这是一个向数据库发送新数据的好地方。需要注意的是,突变会返回一个承诺。这意味着我们可以用then()/catch()/finally()try/catch/finally搭配async/await。我们能在突变中发送什么?更重要的是,我们如何使用它?一旦我们从钩子中获得了 mutator 函数,我们就可以通过提供一个 option 对象来调用它,这个对象至少应该包含变异变量。下面的例子说明了我们如何使用这种突变:

...
   await createInvoice({
     variables: {
       invoice: {
         user: 1,
         date: "2021-05-01",
         dueDate: "2021-05-31",
         state: "UNPAID",
         items: [
           {
             description: "Django consulting",
             price: 7000,
             taxed: true,
             quantity: 1,
           },
         ],
       },
     },
   });
...

在这个变异中,我们发送变异的整个输入类型,就像我们的模式中声明的那样。在这个例子中,我们硬编码了一些数据,但是在现实世界中,我们可能希望用 JavaScript 动态地获得突变变量。这正是我们在第六章中所做的,当时我们从一个带有FormData的表单中构建了一个POST有效载荷。让我们通过添加适当的输入和提交按钮来完成我们的表单。首先,清单 12-18 展示了完整的 React 表单(为了简洁,我们在这里忽略了任何 CSS 和风格方面的问题)。

<Form handleSubmit={handleSubmit}>
 <Select
   id="user"
   name="user"
   options={data.getClients}
 />
 <div>
   <label htmlFor="date">Date</label>
   <input id="date" name="date" type="date" required />
 </div>
 <div>
   <label htmlFor="dueDate">Due date</label>
   <input
     id="dueDate"
     name="dueDate"
     type="date"
     required
   />
 </div>
 <div>
   <label htmlFor="quantity">Qty</label>
   <input
     id="quantity"
     name="quantity"
     type="number"
     min="0"
     max="10"
     required
   />
 </div>

 <div>
   <label htmlFor="description">Description</label>
   <input
     id="description"
     name="description"
     type="text"
     required
   />
 </div>
 <div>
   <label htmlFor="price">Price</label>
   <input
     id="price"
     name="price"
     type="number"
     min="0"
     step="0.01"
     required
   />
 </div>
 <div>
   <label htmlFor="taxed">Taxed</label>
   <input id="taxed" name="taxed" type="checkbox" />
 </div>
 {mutationLoading ? (
   <p>Creating the invoice ...</p>
 ) : (
   <button type="submit">CREATE INVOICE</button>
 )}
</Form>

Listing 12-18src/App.tsx - The Complete Form

该表单包含创建新发票的所有输入。在底部,注意基于mutationLoading状态的条件渲染。

为了通知用户请求的状态,这是一件很好的事情。有了表单,我们就可以组装从handleSubmit()发出突变的逻辑了。为了方便起见,我们可以将async/awaittry/catch一起使用。看代码前的一些话:

  • 我们从一个FormData开始构建突变有效载荷

  • 在构建逻辑中,我们将quantity转换为整数,将taxed转换为布尔值

最后这些步骤是必要的,因为我们的 GraphQL 模式期望 quantity 是一个整数,而在表单中它只是一个字符串。清单 12-19 显示了完整的代码。

const handleSubmit = async (
 event: React.FormEvent<HTMLFormElement>
) => {
 event.preventDefault();
 if (event.target instanceof HTMLFormElement) {
   const formData = new FormData(event.target);

   const invoice = {
     user: formData.get("user"),
     date: formData.get("date"),
     dueDate: formData.get("dueDate"),
     state: "UNPAID",
     items: [
       {
         quantity: parseInt(
           formData.get("quantity") as string
         ),
         description: formData.get("description"),
         price: formData.get("price"),
         taxed: Boolean(formData.get("taxed")),
       },
     ],
   };

   try {
     const { data } = await createInvoice({
       variables: { invoice },
     });
     event.target.reset();
   } catch (error) {
     console.error(error);
   }
 }
};

Listing 12-19src/App.tsx - Logic for Sending the Mutation

除了FormData逻辑之外,其余部分非常简单:

  • 我们通过提供一个invoice有效载荷来发送带有createInvoice()的变异

  • 如果一切顺利,我们用event.target.reset()重置表单

如果我们在浏览器中测试,我们应该能够发出变异并从服务器得到响应。这个过程可以在浏览器的控制台中看到,如图 12-1 所示,其中突出显示了响应选项卡。

img/505838_1_En_12_Fig1_HTML.jpg

图 12-1

来自 GraphQL 服务器的变异响应

在 REST 中,当我们用一个POSTPATCH请求创建或修改一个资源时,API 用一个有效负载来响应。GraphQL 没有例外。事实上,我们可以访问突变的响应数据,如下面的代码片段所示:

const { data } = await createInvoice({
 variables: { invoice },
});
// do something with the data

在图 12-1 中,我们可以看到data对象包含一个名为createInvoice的属性,它保存了我们从变异中请求的字段。我们还可以看到__typename。这是 GraphQL 自省功能的一部分,它使得询问 GraphQL“这个对象是什么类型”成为可能?对 GraphQL 自省的解释超出了本书的范围,但是官方文档是了解更多信息的良好起点。

Note

这是提交到目前为止所做的更改并将工作推送到 Git repo 的好时机。你可以在 https://github.com/valentinogagliardi/decoupled-dj/tree/chapter_12_graphql_strawberry 找到本章的源代码。

下一步是什么?

在最后几页中,我们仅仅触及了 GraphQL 的皮毛。题目比两章还大。以下是读完这本书后你可以自己探索的主题列表:

  • 认证和部署:在完全解耦的设置中,GraphQL 可以很好地使用 JWT 令牌进行认证。然而,该规范并不强制任何特定类型的身份验证方法。这意味着对 GraphQL API 使用基于会话的认证是可能的,正如我们在第十章中看到的 REST。

  • 订阅:Django 的 GraphQL Python 库可以与 Django 通道集成,通过 WebSocket 提供订阅。

  • 测试:测试 GraphQL API 不涉及任何魔法。因为它们接受并返回 JSON,所以任何用于 Python 或 Django 的测试 HTTP 客户端都可以用来测试 GraphQL 端点。

  • 排序、过滤和分页:使用 Django 和 Django REST 框架工具很容易对响应进行排序、过滤和分页。然而,要在 GraphQL 中实现同样的东西,我们需要手工编写一些代码。但是由于 GraphQL 查询接受参数,所以在 GraphQL API 中构建自定义过滤功能并不困难。

  • 性能 : 由于 GraphQL 中的查询可以嵌套,所以必须非常小心避免 N+1 个查询使我们的数据库崩溃。大多数 GraphQL 库包括一个所谓的数据加载器,它负责缓存数据库查询。

Exercise 12-1: Adding More Asynchronous Mutations

第六章中的线框有一个发送电子邮件按钮。尝试在 React 前端实现这一逻辑,并做一些改动。在后端,您还需要一个新的异步变异来发送电子邮件。

Exercise 12-2: Testing GraphQL

向这个简单的应用添加测试:可以用 Django 测试工具测试 GraphQL 端点,用 Cypress 测试接口。

摘要

在这一章中,我们用 GraphQL 和异步 Django 的基础知识结束了这个循环。您学习了如何:

  • 在后端和前端使用 GraphQL 变体

  • 使用异步 Django

现在轮到你了!去构建你的下一个 Django 项目吧!

额外资源

posted @ 2024-08-13 14:27  绝不原创的飞龙  阅读(5)  评论(0编辑  收藏  举报