RedisOM for Node.js

了解如何使用 Redis Stack 和 Node.js 进行构建

本教程将向您展示如何使用 Node.js 和 Redis Stack 构建 API。

我们将使用 ExpressRedis OM 来完成此操作,我们假设您对 Express 有基本了解。

我们将构建的 API 是一个简单且相对 RESTful 的 API,它读取、写入和查找有关人员的数据:名字、姓氏、年龄等。我们还将添加一个简单的位置跟踪功能,仅仅是为了增加一些额外的东西。

但在我们开始编码之前,让我们先来描述一下 Redis OM 是什么。

先决条件

与任何与软件相关的东西一样,您需要在开始之前安装一些依赖项。

  • Node.js 14.8+:在本教程中,我们使用 JavaScript 的顶级 await 特性,该特性是在 Node 14.8 中引入的。因此,请确保您使用的是该版本或更高版本。
  • Redis Stack: 您需要一个 Redis Stack 版本,它可以在您的机器上本地运行,或者在云端运行。
  • Redis Insight: 我们将使用它来查看 Redis 内部,并确保我们的代码按预期运行。

入门代码

我们不会从头开始编写代码。相反,我们为您提供了一些入门代码。您可以将其克隆到您方便的文件夹中。

git clone git@github.com:redis-developer/express-redis-om-workshop.git

现在您有了入门代码,让我们来探索一下。在根目录中打开 server.js,我们可以看到这是一个简单的 Express 应用程序,它使用 Dotenv 进行配置,并使用 Swagger UI Express 测试我们的 API。

import 'dotenv/config'

import express from 'express'
import swaggerUi from 'swagger-ui-express'
import YAML from 'yamljs'

/* create an express app and use JSON */
const app = new express()
app.use(express.json())

/* set up swagger in the root */
const swaggerDocument = YAML.load('api.yaml')
app.use('/', swaggerUi.serve, swaggerUi.setup(swaggerDocument))

/* start the server */
app.listen(8080)

与之配套的是 api.yaml,它定义了我们要构建的 API,并提供 Swagger UI Express 渲染其 UI 所需的信息。除非您想添加一些额外的路由,否则您无需修改它。

persons 文件夹中包含一些 JSON 文件和一个 shell 脚本。JSON 文件是示例人员——都是音乐家,因为有趣——您可以将其加载到 API 中进行测试。shell 脚本——load-data.sh——将使用 curl 将所有 JSON 文件加载到 API 中。

有两个空文件夹:omroutersom 文件夹是所有 Redis OM 代码将存放的地方。routers 文件夹将保存我们所有 Express 路由的代码。

配置和运行

入门代码是完全可运行的,即使它有点薄弱。让我们配置并运行它,以确保它在继续编写实际代码之前工作正常。首先,获取所有依赖项

npm install

然后,在根目录中设置一个 .env 文件,Dotenv 可以使用它。根目录中有一个 sample.env 文件,您可以复制并修改它

cp sample.env .env

.env 的内容如下

# Put your local Redis Stack URL here. Want to run in the
# cloud instead? Sign up at https://redis.com/try-free/.
REDIS_URL=redis://localhost:6379

很有可能这已经是正确的了。但是,如果您需要更改特定环境的 REDIS_URL(例如,您在云端运行 Redis Stack),那么现在就该更改了。完成之后,您应该能够运行应用程序

npm start

导航到 http://localhost:8080,查看 Swagger UI Express 创建的客户端。目前它还没有工作,因为我们还没有实现任何路由。但是,您可以尝试一下,看看它们是如何失败的!

入门代码运行成功。让我们添加一些 Redis OM,使其真正发挥作用!

设置客户端

首先,让我们设置一个客户端Client 类是知道如何代表 Redis OM 与 Redis 交互的类。一种选择是将我们的客户端放在它自己的文件中并导出它。这样可以确保应用程序只有一个 Client 实例,从而只有一个与 Redis Stack 的连接。由于 Redis 和 JavaScript 都是(或多或少)单线程的,因此这可以很好地工作。

让我们创建我们的第一个文件。在 om 文件夹中添加一个名为 client.js 的文件,并添加以下代码

import { Client } from 'redis-om'

/* pulls the Redis URL from .env */
const url = process.env.REDIS_URL

/* create and open the Redis OM Client */
const client = await new Client().open(url)

export default client

还记得我们之前提到的顶层等待吗?它就在这里!

请注意,我们正在从环境变量中获取 Redis URL。它是由 Dotenv 放置在那里的,并从我们的 .env 文件中读取。如果我们没有 .env 文件,或者我们的 .env 文件中没有 REDIS_URL 属性,这段代码将很乐意从实际环境变量中读取该值。

另外请注意,.open() 方法方便地返回了 this。这个 this(我可以再重复一次this吗?我刚刚重复了!)让我们可以将客户端的实例化与客户端的打开链接在一起。如果不喜欢这样做,您也可以这样写

/* create and open the Redis OM Client */
const client = new Client()
await client.open(url)

实体、模式和存储库

现在我们有了连接到 Redis 的客户端,我们需要开始映射一些人员。为此,我们需要定义一个 Entity 和一个 Schema。让我们从在 om 文件夹中创建一个名为 person.js 的文件开始,并从 client.js 中导入 client,以及从 Redis OM 中导入 EntitySchema

import { Entity, Schema } from 'redis-om'
import client from './client.js'

实体

接下来,我们需要定义一个实体Entity 是一个类,当您使用它时,它会保存您的数据——要映射的内容。它是您创建、读取、更新和删除的对象。任何扩展 Entity 的类都是一个实体。我们将用一行代码定义我们的 Person 实体

/* our entity */
class Person extends Entity {}

模式

模式定义了实体上的字段、它们的类型以及它们在 Redis 内部如何映射。默认情况下,实体映射到 JSON 文档。让我们在 person.js 中创建我们的 Schema

/* create a Schema for Person */
const personSchema = new Schema(Person, {
  firstName: { type: 'string' },
  lastName: { type: 'string' },
  age: { type: 'number' },
  verified: { type: 'boolean' },
  location: { type: 'point' },
  locationUpdated: { type: 'date' },
  skills: { type: 'string[]' },
  personalStatement: { type: 'text' }
})

当您创建一个 Schema 时,它会修改您传递给它的 Entity 类(本例中为 Person),为定义的属性添加 getter 和 setter。这些 getter 和 setter 接受和返回的类型由上面的类型参数定义。有效值为:stringnumberbooleanstring[]datepointtext

前三个完全符合您的预期——它们定义了一个属性,该属性是一个 String、一个 Number 或一个 Booleanstring[] 也符合您的预期,它专门定义了一个 Array,其中包含字符串。

date 有点不同,但或多或少也符合您的预期。它定义了一个属性,该属性返回一个 Date,并且可以使用 Date、包含 ISO 8601 日期或包含 UNIX 纪元时间(以毫秒为单位)的 String 来设置它。

point 定义了地球上的某个点,作为一个经度和纬度。它创建一个属性,该属性返回并接受一个简单的对象,其中包含 longitudelatitude 属性。就像这样

let point = { longitude: 12.34, latitude: 56.78 }

text 字段很像 string。如果您只是读取和写入对象,它们是相同的。但是,如果您想对其进行搜索,它们将有很大不同。我们稍后会讨论搜索,但简而言之,string 字段只能匹配其整个值(没有部分匹配),最适合用作键,而 text 字段启用了全文搜索,并针对人类可读文本进行了优化。

存储库

现在我们有了创建存储库所需的所有部分。Repository 是 Redis OM 的主要接口。它为我们提供了读取、写入和删除特定 Entity 的方法。在 person.js 中创建一个 Repository,并确保将其导出,因为在开始实现我们的 API 时您会需要它

/* use the client to create a Repository just for Persons */
export const personRepository = new Repository(personSchema, client)

我们离完成存储库的设置已经很近了。但是,我们仍然需要创建一个索引,否则我们就无法进行搜索。我们通过调用 .createIndex() 来实现。如果索引已存在并且与当前索引相同,则此函数不会执行任何操作。如果不同,它将删除该索引并创建一个新的索引。在 person.js 中添加对 .createIndex() 的调用

/* create the index for Person */
await personRepository.createIndex()

这就是 person.js 的全部内容,也是我们使用 Redis OM 开始与 Redis 交互所需的一切。以下是完整代码

import { Entity, Schema } from 'redis-om'
import client from './client.js'

/* our entity */
class Person extends Entity {}

/* create a Schema for Person */
const personSchema = new Schema(Person, {
  firstName: { type: 'string' },
  lastName: { type: 'string' },
  age: { type: 'number' },
  verified: { type: 'boolean' },
  location: { type: 'point' },
  locationUpdated: { type: 'date' },
  skills: { type: 'string[]' },
  personalStatement: { type: 'text' }
})

/* use the client to create a Repository just for Persons */
export const personRepository = client.fetchRepository(personSchema)

/* create the index for Person */
await personRepository.createIndex()

现在,让我们在 Express 中添加一些路由。

设置人员路由

让我们使用 CRUD 操作分别映射到 PUT、GET、POST 和 DELETE,创建一个真正 RESTful 的 API。我们将使用 Express 路由器 来实现,因为这使我们的代码井井有条。在 routers 文件夹中创建一个名为 person-router.js 的文件,并在其中从 Express 中导入 Router,并从 person.js 中导入 personRepository。然后创建并导出一个 Router

import { Router } from 'express'
import { personRepository } from '../om/person.js'

export const router = Router()

完成导入和导出后,让我们将路由器绑定到我们的 Express 应用程序。打开 server.js,导入我们刚刚创建的 Router

/* import routers */
import { router as personRouter } from './routers/person-router.js'

然后将 personRouter 添加到 Express 应用程序中

/* bring in some routers */
app.use('/person', personRouter)

您的 server.js 现在应该如下所示

import 'dotenv/config'

import express from 'express'
import swaggerUi from 'swagger-ui-express'
import YAML from 'yamljs'

/* import routers */
import { router as personRouter } from './routers/person-router.js'

/* create an express app and use JSON */
const app = new express()
app.use(express.json())

/* bring in some routers */
app.use('/person', personRouter)

/* set up swagger in the root */
const swaggerDocument = YAML.load('api.yaml')
app.use('/', swaggerUi.serve, swaggerUi.setup(swaggerDocument))

/* start the server */
app.listen(8080)

现在我们可以添加路由来创建、读取、更新和删除人员。回到 person-router.js 文件,以便我们能够实现这一点。

创建人员

我们先创建一个人,因为您需要在 Redis 中拥有人员才能执行任何读取、写入或删除操作。在下面添加 PUT 路由。此路由将调用 .createAndSave() 以从请求主体创建 Person,并立即将其保存到 Redis 中

router.put('/', async (req, res) => {
  const person = await personRepository.createAndSave(req.body)
  res.send(person)
})

请注意,我们还返回了新创建的 Person。让我们通过使用 Swagger UI 实际调用我们的 API 来看看它是什么样子。在浏览器中转到 http://localhost:8080,并试一下。Swagger 中的默认请求主体将适用于测试。您应该会看到类似于以下的响应

{
  "entityId": "01FY9MWDTWW4XQNTPJ9XY9FPMN",
  "firstName": "Rupert",
  "lastName": "Holmes",
  "age": 75,
  "verified": false,
  "location": {
    "longitude": 45.678,
    "latitude": 45.678
  },
  "locationUpdated": "2022-03-01T12:34:56.123Z",
  "skills": [
    "singing",
    "songwriting",
    "playwriting"
  ],
  "personalStatement": "I like piña coladas and walks in the rain"
}

这与我们传递给它的内容完全相同,只有一个例外:entityId。Redis OM 中的每个实体都有一个实体 ID,顾名思义,它是该实体的唯一 ID。它是我们在调用 .createAndSave() 时随机生成的。您的 ID 将不同,因此请记下它。

您可以使用 Redis Insight 在 Redis 中查看这个新创建的 JSON 文档。启动 Redis Insight,您应该会看到一个名为 Person:01FY9MWDTWW4XQNTPJ9XY9FPMN 的键。键中的 Person 部分是从我们实体的类名派生出来的,字母和数字序列是我们的生成实体 ID。单击它,查看您创建的 JSON 文档。

您还会看到一个名为 Person:index:hash 的键。这是一个 Redis OM 用来查看在调用 .createIndex() 时是否需要重新创建索引的唯一值。您可以安全地忽略它。

读取人员

创建完成,让我们添加一个 GET 路由来读取这个新创建的 Person

router.get('/:id', async (req, res) => {
  const person = await personRepository.fetch(req.params.id)
  res.send(person)
})

此代码从路由中使用的 URL 中提取一个参数——之前收到的 entityId。它使用 personRepository 上的 .fetch() 方法使用该 entityId 检索 Person。然后,它返回该 Person

让我们在 Swagger 中也测试一下。您应该会收到完全相同的响应。事实上,由于这是一个简单的 GET,我们应该能够将 URL 直接加载到我们的浏览器中。通过导航到 http://localhost:8080/person/01FY9MWDTWW4XQNTPJ9XY9FPMN 来测试一下,将实体 ID 替换为您自己的 ID。

现在我们可以读取和写入,让我们实现 HTTP 动词的剩余部分。REST... 懂了吗?

更新人员

让我们添加使用 POST 路由更新人员的代码

router.post('/:id', async (req, res) => {

  const person = await personRepository.fetch(req.params.id)

  person.firstName = req.body.firstName ?? null
  person.lastName = req.body.lastName ?? null
  person.age = req.body.age ?? null
  person.verified = req.body.verified ?? null
  person.location = req.body.location ?? null
  person.locationUpdated = req.body.locationUpdated ?? null
  person.skills = req.body.skills ?? null
  person.personalStatement = req.body.personalStatement ?? null

  await personRepository.save(person)

  res.send(person)
})

这段代码从 personRepository 中使用 entityId 获取 Person,就像我们之前的路由一样。但是,现在我们根据请求体中的属性更改所有属性。如果缺少任何属性,我们将它们设置为 null。然后,我们调用 .save() 并返回更改后的 Person

我们也来试试在 Swagger 中测试一下吧,为什么不呢?做一些更改。尝试删除一些字段。当你更改后读取它时,你会得到什么?

删除 Person

删除——我最喜欢的!记住孩子们,删除就是 100% 的压缩。删除的路由与读取的路由一样简单,但破坏性更大

router.delete('/:id', async (req, res) => {
  await personRepository.remove(req.params.id)
  res.send({ entityId: req.params.id })
})

我想我们也应该测试一下这个。打开 Swagger 并测试该路由。你应该得到一个 JSON,其中包含你刚刚删除的实体 ID

{
  "entityId": "01FY9MWDTWW4XQNTPJ9XY9FPMN"
}

就这样,它消失了!

所有的 CRUD

快速检查一下你目前写的内容。以下是你的 person-router.js 文件的全部内容

import { Router } from 'express'
import { personRepository } from '../om/person.js'

export const router = Router()

router.put('/', async (req, res) => {
  const person = await personRepository.createAndSave(req.body)
  res.send(person)
})

router.get('/:id', async (req, res) => {
  const person = await personRepository.fetch(req.params.id)
  res.send(person)
})

router.post('/:id', async (req, res) => {

  const person = await personRepository.fetch(req.params.id)

  person.firstName = req.body.firstName ?? null
  person.lastName = req.body.lastName ?? null
  person.age = req.body.age ?? null
  person.verified = req.body.verified ?? null
  person.location = req.body.location ?? null
  person.locationUpdated = req.body.locationUpdated ?? null
  person.skills = req.body.skills ?? null
  person.personalStatement = req.body.personalStatement ?? null

  await personRepository.save(person)

  res.send(person)
})

router.delete('/:id', async (req, res) => {
  await personRepository.remove(req.params.id)
  res.send({ entityId: req.params.id })
})

CRUD 完成,我们来做一些搜索。为了搜索,我们需要搜索的数据。还记得那个包含所有 JSON 文档的 persons 文件夹和 load-data.sh shell 脚本吗?现在到了它发挥作用的时候了。进入该文件夹并运行该脚本

cd persons
./load-data.sh

你应该得到一个比较详细的响应,其中包含来自 API 的 JSON 响应以及你加载的文件名称。就像这样

{"entityId":"01FY9Z4RRPKF4K9H78JQ3K3CP3","firstName":"Chris","lastName":"Stapleton","age":43,"verified":true,"location":{"longitude":-84.495,"latitude":38.03},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","football","coal mining"],"personalStatement":"There are days that I can walk around like I'm alright. And I pretend to wear a smile on my face. And I could keep the pain from comin' out of my eyes. But sometimes, sometimes, sometimes I cry."} <- chris-stapleton.json
{"entityId":"01FY9Z4RS2QQVN4XFYSNPKH6B2","firstName":"David","lastName":"Paich","age":67,"verified":false,"location":{"longitude":-118.25,"latitude":34.05},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","keyboard","blessing"],"personalStatement":"I seek to cure what's deep inside frightened of this thing that I've become"} <- david-paich.json
{"entityId":"01FY9Z4RSD7SQMSWDFZ6S4M5MJ","firstName":"Ivan","lastName":"Doroschuk","age":64,"verified":true,"location":{"longitude":-88.273,"latitude":40.115},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","dancing","friendship"],"personalStatement":"We can dance if we want to. We can leave your friends behind. 'Cause your friends don't dance and if they don't dance well they're no friends of mine."} <- ivan-doroschuk.json
{"entityId":"01FY9Z4RSRZFGQ21BMEKYHEVK6","firstName":"Joan","lastName":"Jett","age":63,"verified":false,"location":{"longitude":-75.273,"latitude":40.003},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","guitar","black eyeliner"],"personalStatement":"I love rock n' roll so put another dime in the jukebox, baby."} <- joan-jett.json
{"entityId":"01FY9Z4RT25ABWYTW6ZG7R79V4","firstName":"Justin","lastName":"Timberlake","age":41,"verified":true,"location":{"longitude":-89.971,"latitude":35.118},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","dancing","half-time shows"],"personalStatement":"What goes around comes all the way back around."} <- justin-timberlake.json
{"entityId":"01FY9Z4RTD9EKBDS2YN9CRMG1D","firstName":"Kerry","lastName":"Livgren","age":72,"verified":false,"location":{"longitude":-95.689,"latitude":39.056},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["poetry","philosophy","songwriting","guitar"],"personalStatement":"All we are is dust in the wind."} <- kerry-livgren.json
{"entityId":"01FY9Z4RTR73HZQXK83JP94NWR","firstName":"Marshal","lastName":"Mathers","age":49,"verified":false,"location":{"longitude":-83.046,"latitude":42.331},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["rapping","songwriting","comics"],"personalStatement":"Look, if you had, one shot, or one opportunity to seize everything you ever wanted, in one moment, would you capture it, or just let it slip?"} <- marshal-mathers.json
{"entityId":"01FY9Z4RV2QHH0Z1GJM5ND15JE","firstName":"Rupert","lastName":"Holmes","age":75,"verified":true,"location":{"longitude":-2.518,"latitude":53.259},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","songwriting","playwriting"],"personalStatement":"I like piña coladas and taking walks in the rain."} <- rupert-holmes.json

有点乱,但如果你没有看到这个,那就说明它没有成功!

现在我们有了数据,让我们添加另一个路由来保存我们想要添加的搜索路由。在 routers 文件夹中创建一个名为 search-router.js 的文件,并像我们在 person-router.js 中一样设置导入和导出

import { Router } from 'express'
import { personRepository } from '../om/person.js'

export const router = Router()

像我们对 personRouter 所做的那样,将 Router 导入到 server.js

/* import routers */
import { router as personRouter } from './routers/person-router.js'
import { router as searchRouter } from './routers/search-router.js'

然后将 searchRouter 添加到 Express 应用程序中

/* bring in some routers */
app.use('/person', personRouter)
app.use('/persons', searchRouter)

路由绑定后,我们现在可以添加一些路由了。

搜索所有东西

我们将向我们的新 Router 添加大量搜索。但第一个是最简单的,因为它只会返回所有内容。继续将以下代码添加到 search-router.js

router.get('/all', async (req, res) => {
  const persons = await personRepository.search().return.all()
  res.send(persons)
})

这里我们看到了如何开始和结束搜索。搜索的开始方式与 CRUD 操作的开始方式相同——在 Repository 上。但我们没有调用 .createAndSave().fetch().save().remove(),而是调用 .search()。与所有其他方法不同,.search() 不会就此结束。相反,它允许你构建一个查询(你将在下一个示例中看到),然后通过调用 .return.all() 来解析它。

有了这个新路由,进入 Swagger UI 并测试 /persons/all 路由。你应该看到所有你用 shell 脚本添加的人,以 JSON 数组的形式显示。

在上面的示例中,没有指定查询——我们没有构建任何东西。如果你这样做,你只会得到所有内容。这在某些情况下是你想要的。但大多数情况下不是。如果你只是返回所有内容,那就不算是真正的搜索。因此,让我们添加一个路由,让我们根据姓氏查找人员。添加以下代码

router.get('/by-last-name/:lastName', async (req, res) => {
  const lastName = req.params.lastName
  const persons = await personRepository.search()
    .where('lastName').equals(lastName).return.all()
  res.send(persons)
})

在这个路由中,我们指定了要过滤的字段以及它需要等于的值。对 .where() 的调用中的字段名称是我们在模式中指定的字段名称。该字段被定义为 string,这很重要,因为字段的类型决定了可用于查询它的方法。

对于 string,只存在 .equals(),它将针对整个字符串的值进行查询。这被别名为 .eq().equal().equalTo(),方便你使用。你甚至可以通过调用 .is.does 添加更多语法糖,它们实际上什么也不做,只是让你的代码更漂亮。就像这样

const persons = await personRepository.search().where('lastName').is.equalTo(lastName).return.all()
const persons = await personRepository.search().where('lastName').does.equal(lastName).return.all()

你也可以通过调用 .not 来反转查询

const persons = await personRepository.search().where('lastName').is.not.equalTo(lastName).return.all()
const persons = await personRepository.search().where('lastName').does.not.equal(lastName).return.all()

在所有这些情况下,对 .return.all() 的调用执行了我们在它和对 .search() 的调用之间构建的查询。我们也可以搜索其他字段类型。让我们添加一些路由来搜索 numberboolean 字段

router.get('/old-enough-to-drink-in-america', async (req, res) => {
  const persons = await personRepository.search()
    .where('age').gte(21).return.all()
  res.send(persons)
})

router.get('/non-verified', async (req, res) => {
  const persons = await personRepository.search()
    .where('verified').is.not.true().return.all()
  res.send(persons)
})

number 字段根据年龄过滤人员,其中年龄大于或等于 21。同样,也有一些别名和语法糖

const persons = await personRepository.search().where('age').is.greaterThanOrEqualTo(21).return.all()

但也有一些其他查询方式

const persons = await personRepository.search().where('age').eq(21).return.all()
const persons = await personRepository.search().where('age').gt(21).return.all()
const persons = await personRepository.search().where('age').gte(21).return.all()
const persons = await personRepository.search().where('age').lt(21).return.all()
const persons = await personRepository.search().where('age').lte(21).return.all()
const persons = await personRepository.search().where('age').between(21, 65).return.all()

boolean 字段根据人员的验证状态进行搜索。它已经包含了一些语法糖。请注意,此查询将匹配缺失的值或 false 值。这就是我指定 .not.true() 的原因。你也可以在布尔字段上调用 .false(),以及所有 .equals 的变体。

const persons = await personRepository.search().where('verified').true().return.all()
const persons = await personRepository.search().where('verified').false().return.all()
const persons = await personRepository.search().where('verified').equals(true).return.all()

因此,我们创建了一些路由,但我还没有告诉你测试它们。也许你已经测试过了。如果是这样,很好,你真叛逆。对于你们其他人,为什么不现在就用 Swagger 测试一下呢?而且,从现在开始,当你想要测试时就测试它们。说真的,用提供的语法创建一些你自己的路由,然后尝试一下。别让我来告诉你该怎么生活。

当然,仅在一个字段上进行查询永远不够。这没问题,Redis OM 可以处理 .and().or(),就像在这个路由中一样

router.get('/verified-drinkers-with-last-name/:lastName', async (req, res) => {
  const lastName = req.params.lastName
  const persons = await personRepository.search()
    .where('verified').is.true()
      .and('age').gte(21)
      .and('lastName').equals(lastName).return.all()
  res.send(persons)
})

在这里,我只展示了 .and() 的语法,但当然,你也可以使用 .or()

如果你在模式中定义了一个类型为 text 的字段,你可以在该字段上执行全文搜索。text 字段的搜索方式与 string 字段的搜索方式不同。string 只能与 .equals() 进行比较,并且必须与整个字符串匹配。对于 text 字段,你可以查找字符串中的单词。

text 字段针对人类可读的文本进行了优化,例如文章或歌曲歌词。它非常聪明。它理解某些词(例如 aanthe)很常见,并且忽略它们。它理解单词在语法上的相似性,因此如果你搜索 give,它也会匹配 givesgivengivinggave。并且它会忽略标点符号。

让我们添加一个路由,对我们的 personalStatement 字段进行全文搜索

router.get('/with-statement-containing/:text', async (req, res) => {
  const text = req.params.text
  const persons = await personRepository.search()
    .where('personalStatement').matches(text)
      .return.all()
  res.send(persons)
})

请注意 .matches() 函数的使用。这是唯一适用于 text 字段的函数。它接受一个字符串,该字符串可以是一个或多个单词——以空格分隔——你想查询的单词。让我们试试。在 Swagger 中,使用此路由搜索单词 "walk"。你应该看到以下结果

[
  {
    "entityId": "01FYC7CTR027F219455PS76247",
    "firstName": "Rupert",
    "lastName": "Holmes",
    "age": 75,
    "verified": true,
    "location": {
      "longitude": -2.518,
      "latitude": 53.259
    },
    "locationUpdated": "2022-01-01T12:00:00.000Z",
    "skills": [
      "singing",
      "songwriting",
      "playwriting"
    ],
    "personalStatement": "I like piña coladas and taking walks in the rain."
  },
  {
    "entityId": "01FYC7CTNBJD9CZKKWPQEZEW14",
    "firstName": "Chris",
    "lastName": "Stapleton",
    "age": 43,
    "verified": true,
    "location": {
      "longitude": -84.495,
      "latitude": 38.03
    },
    "locationUpdated": "2022-01-01T12:00:00.000Z",
    "skills": [
      "singing",
      "football",
      "coal mining"
    ],
    "personalStatement": "There are days that I can walk around like I'm alright. And I pretend to wear a smile on my face. And I could keep the pain from comin' out of my eyes. But sometimes, sometimes, sometimes I cry."
  }
]

注意单词 "walk" 如何匹配 Rupert Holmes 的个人声明,其中包含 "walks",并且匹配 Chris Stapleton 的个人声明,其中包含 "walk"。现在搜索 "walk raining"。你会看到,这只会返回 Rupert 的条目,即使这两个词的准确文本都没有在他的个人声明中找到。但它们在语法上相关,因此它匹配了它们。这叫做词干提取,这是 Redis Stack 的一个非常酷的功能,Redis OM 可以免费利用它。

如果你搜索 "a rain walk",你仍然会匹配 Rupert 的条目,即使文本中没有 "a" 这个词。为什么?因为它是一个常见的词,对搜索没有太大帮助。这些常见词被称为停用词,这是 Redis Stack 的另一个很酷的功能,Redis OM 可以免费利用它。

搜索全球

Redis Stack 以及 Redis OM 都支持按地理位置搜索。你指定地球上的一个点、一个半径以及该半径的单位,它会很乐意返回该范围内的所有实体。让我们添加一个路由来做到这一点

router.get('/near/:lng,:lat/radius/:radius', async (req, res) => {
  const longitude = Number(req.params.lng)
  const latitude = Number(req.params.lat)
  const radius = Number(req.params.radius)

  const persons = await personRepository.search()
    .where('location')
      .inRadius(circle => circle
          .longitude(longitude)
          .latitude(latitude)
          .radius(radius)
          .miles)
        .return.all()

  res.send(persons)
})

这段代码看起来与其他代码有点不同,因为我们定义想要搜索的圆圈的方式是通过一个函数来完成,该函数被传递给 .inRadius 方法

circle => circle.longitude(longitude).latitude(latitude).radius(radius).miles

此函数所做的只是接受一个 Circle 的实例,该实例已使用默认值初始化。我们通过调用各种构建器方法来覆盖这些值,以定义搜索的原点(即经度和纬度)、半径以及半径的测量单位。有效的单位是 milesmetersfeetkilometers

让我们尝试一下该路由。我知道我们可以在经度约 -75.0 和纬度 40.0 左右找到 Joan Jett,该位置位于宾夕法尼亚州东部。因此,使用这些坐标,半径为 20 英里。你应该在响应中收到

[
  {
    "entityId": "01FYC7CTPKYNXQ98JSTBC37AS1",
    "firstName": "Joan",
    "lastName": "Jett",
    "age": 63,
    "verified": false,
    "location": {
      "longitude": -75.273,
      "latitude": 40.003
    },
    "locationUpdated": "2022-01-01T12:00:00.000Z",
    "skills": [
      "singing",
      "guitar",
      "black eyeliner"
    ],
    "personalStatement": "I love rock n' roll so put another dime in the jukebox, baby."
  }
]

尝试扩大半径,看看你还能找到谁。

添加位置跟踪

我们快接近本教程的结尾了,但在我们离开之前,我想添加一下我在开头提到的位置跟踪部分。如果你已经走到了这一步,那么这段代码应该很容易理解,因为它实际上并没有做我还没有谈论过的事情。

routers 文件夹中添加一个名为 location-router.js 的新文件

import { Router } from 'express'
import { personRepository } from '../om/person.js'

export const router = Router()

router.patch('/:id/location/:lng,:lat', async (req, res) => {

  const id = req.params.id
  const longitude = Number(req.params.lng)
  const latitude = Number(req.params.lat)

  const locationUpdated = new Date()

  const person = await personRepository.fetch(id)
  person.location = { longitude, latitude }
  person.locationUpdated = locationUpdated
  await personRepository.save(person)

  res.send({ id, locationUpdated, location: { longitude, latitude } })
})

在这里,我们调用 .fetch() 来获取一个人,我们正在更新该人的一些值——.location 属性包含我们的经度和纬度,.locationUpdated 属性包含当前日期和时间。很简单。

要使用此 Router,请在 server.js 中导入它

/* import routers */
import { router as personRouter } from './routers/person-router.js'
import { router as searchRouter } from './routers/search-router.js'
import { router as locationRouter } from './routers/location-router.js'

并将路由绑定到一个路径

/* bring in some routers */
app.use('/person', personRouter, locationRouter)
app.use('/persons', searchRouter)

就这样。但这还不够。它没有向你展示任何新东西,除了可能使用 date 字段。而且,它实际上不是位置跟踪。它只显示这些人最后在哪里,没有历史记录。所以让我们添加一些历史记录!

要添加一些历史记录,我们将使用 Redis Stream。Stream 是一个很大的话题,如果你不熟悉它们,别担心,你可以把它们看作是在 Redis 键中存储的一种日志文件,其中每个条目代表一个事件。在我们的例子中,事件就是人四处走动或签到,或者其他任何事情。

但有一个问题。Redis OM 不支持 Stream,即使 Redis Stack 支持。那么如何在我们的应用程序中利用它们呢?通过使用 Node Redis。Node Redis 是 Node.js 的一个低级 Redis 客户端,它使你能够访问所有 Redis 命令和数据类型。在内部,Redis OM 正在创建和使用 Node Redis 连接。你也可以使用该连接。或者更确切地说,Redis OM 可以被告知使用你正在使用的连接。让我告诉你如何操作。

使用 Node Redis

打开 om 文件夹中的 client.js。还记得我们如何创建一个 Redis OM Client,然后在它上面调用 .open() 吗?

const client = await new Client().open(url)

好吧,Client 类还有一个 .use() 方法,它接受一个 Node Redis 连接。修改 client.js 以使用 Node Redis 打开与 Redis 的连接,然后 .use()

import { Client } from 'redis-om'
import { createClient } from 'redis'

/* pulls the Redis URL from .env */
const url = process.env.REDIS_URL

/* create a connection to Redis with Node Redis */
export const connection = createClient({ url })
await connection.connect()

/* create a Client and bind it to the Node Redis connection */
const client = await new Client().use(connection)

export default client

就是这样。Redis OM 现在正在使用你创建的 connection。请注意,我们正在导出 clientconnection。如果我们想在最新的路由中使用 connection,就必须导出它。

使用 Stream 存储位置历史记录

要将事件添加到 Stream,我们需要使用 XADD 命令。Node Redis 将其公开为 .xAdd()。因此,我们需要在路由中添加对 .xAdd() 的调用。修改 location-router.js 以导入我们的 connection

import { connection } from '../om/client.js'

然后在路由本身中添加对 .xAdd() 的调用

  ...snip...
  const person = await personRepository.fetch(id)
  person.location = { longitude, latitude }
  person.locationUpdated = locationUpdated
  await personRepository.save(person)

  let keyName = `${person.keyName}:locationHistory`
  await connection.xAdd(keyName, '*', person.location)
  ...snip...

.xAdd() 接受一个键名、一个事件 ID 和一个 JavaScript 对象,该对象包含构成事件的键值对,即事件数据。对于键名,我们使用 PersonEntity 继承的 .keyName 属性构建一个字符串(这将返回类似 Person:01FYC7CTPKYNXQ98JSTBC37AS1 的内容),并与一个硬编码值组合。我们传递 * 作为事件 ID,这告诉 Redis 根据当前时间和上一个事件 ID 生成事件 ID。我们传递位置作为事件数据,该位置包含经度和纬度属性。

现在,只要这条路由被调用,经度和纬度就会被记录,并且事件 ID 将对时间进行编码。使用 Swagger 将 Joan Jett 移动几次。

现在,进入 Redis Insight 并查看 Stream。您将在键列表中看到它,但如果您点击它,您会收到一条消息说“此数据类型即将推出!”。如果您没有收到此消息,恭喜您,您生活在未来!对于我们这些生活在过去的人来说,我们将直接发出原始命令。

XRANGE Person:01FYC7CTPKYNXQ98JSTBC37AS1:locationHistory - +

这告诉 Redis 从给定键名(在本例中为 Person:01FYC7CTPKYNXQ98JSTBC37AS1:locationHistory)中存储的 Stream 获取一系列值。接下来的值是起始事件 ID 和结束事件 ID。- 是 Stream 的开头。+ 是结尾。因此,这会返回 Stream 中的所有内容。

1) 1) "1647536562911-0"
  2) 1) "longitude"
      2) "45.678"
      3) "latitude"
      4) "45.678"
2) 1) "1647536564189-0"
  2) 1) "longitude"
      2) "45.679"
      3) "latitude"
      4) "45.679"
3) 1) "1647536565278-0"
  2) 1) "longitude"
      2) "45.680"
      3) "latitude"
      4) "45.680"

就这样,我们正在跟踪 Joan Jett。

总结

所以,现在您知道如何使用 Express + Redis OM 构建一个由 Redis Stack 支持的 API。并且,在这个过程中,您已经获得了相当不错的启动代码。太棒了!如果您想了解更多信息,可以查看 Redis OM 文档。它涵盖了 Redis OM 功能的全部范围。

感谢您抽出时间完成这些步骤。我真诚地希望您觉得它有用。如果您有任何问题,Redis Discord 服务器 是获得解答的最佳场所。加入服务器并提问吧!

RATE THIS PAGE
Back to top ↑