Node.js 的 RedisOM
学习如何使用Redis Stack和Node.js进行构建
本教程将向您展示如何使用Node.js和Redis Stack构建API。
我们将使用Express和Redis 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 中。
有两个空文件夹,om
和 routers
。om
文件夹将存放所有 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
还记得我们之前提到的顶层await吗?它就在这里!
请注意,我们正在从环境变量中获取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导入Entity
和Schema
类:
import { Entity, Schema } from 'redis-om'
import client from './client.js'
实体
接下来,我们需要定义一个实体。一个Entity
是你在处理数据时保存数据的类——被映射的对象。它是你创建、读取、更新和删除的内容。任何继承Entity
的类都是一个实体。我们将用一行代码定义我们的Person
实体:
/* our entity */
class Person extends Entity {}
Schema
一个模式定义了实体上的字段、它们的类型以及它们如何内部映射到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接受和返回的类型由类型参数定义,如上所示。有效值为:string
、number
、boolean
、string[]
、date
、point
和text
。
前三个完全按照你的想法执行——它们定义了一个属性,该属性是一个String
、一个Number
或一个Boolean
。string[]
也按照你的想法执行,具体定义了一个Array
的字符串。
date
有些不同,但仍然大致符合你的预期。它定义了一个返回 Date
的属性,并且不仅可以设置为 Date
,还可以设置为包含 ISO 8601 日期的 String
或包含 UNIX 纪元时间 的 Number
(以毫秒为单位)。
一个point
定义了地球上的某个点,使用经度和纬度表示。它创建了一个属性,该属性返回并接受一个包含longitude
和latitude
属性的简单对象。如下所示:
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()
来实现这一点。如果索引已经存在并且相同,这个函数将不会做任何事情。如果不同,它将删除旧的并创建一个新的。将.createIndex()
的调用添加到person.js
中:
/* 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中添加一些路由。
设置人员路由器
让我们创建一个真正的RESTful API,将CRUD操作分别映射到PUT、GET、POST和DELETE。我们将使用Express Routers来实现这一点,因为这使我们的代码整洁有序。在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()
时,它是随机生成的。你的会不同,所以请记下它。
你可以在Redis中使用Redis Insight查看这个新创建的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替换为你自己的。
既然我们可以读取和写入,现在让我们实现其余的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
中获取Person
,使用entityId
,就像我们之前的路径所做的那样。然而,现在我们根据请求体中的属性更改所有属性。如果其中任何一个缺失,我们将它们设置为null
。然后,我们调用.save()
并返回更改后的Person
。
让我们也在Swagger中测试一下,为什么不呢?做一些更改。尝试删除一些字段。当你更改后读取它时,你会得到什么?
删除人员
删除——我的最爱!记住孩子们,删除是100%的压缩。删除的路径和读取的路径一样直接,但更具破坏性:
router.delete('/:id', async (req, res) => {
await personRepository.remove(req.params.id)
res.send({ entityId: req.params.id })
})
我想我们可能也应该测试一下这个。加载Swagger并执行路由。你应该会得到包含你刚刚删除的实体ID的JSON返回:
{
"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()
将Router
导入server.js
,就像我们对personRouter
所做的那样:
/* 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()
之间构建的查询。我们也可以在其他类型的字段上进行搜索。让我们添加一些路由来在number
和boolean
字段上进行搜索:
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
字段用于根据验证状态搜索人员。它已经包含了一些我们的语法糖。请注意,此查询将匹配缺失值或假值。这就是为什么我指定了 .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
字段是为人类可读的文本优化的,比如一篇文章或歌词。它非常聪明。它理解某些词(如a、an或the)是常见的并忽略它们。它理解单词在语法上的相似性,因此如果你搜索give,它也会匹配gives、given、giving和gave。并且它会忽略标点符号。
让我们添加一个针对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
实例。我们通过调用各种构建器方法来覆盖这些值,以定义搜索的原点(即经度和纬度)、半径以及半径的测量单位。有效的单位是miles
、meters
、feet
和kilometers
。
让我们试试这条路线。我知道我们可以在大约经度-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。流是一个很大的主题,但如果你不熟悉它们也不用担心,你可以把它们想象成存储在Redis键中的一种日志文件,其中每个条目代表一个事件。在我们的例子中,事件可能是人的移动、签到或其他任何行为。
但有一个问题。Redis OM 不支持 Streams,尽管 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
。请注意,我们正在导出 client
和 connection
。如果要在最新路由中使用它,必须导出 connection
。
使用流存储位置历史
要向流中添加事件,我们需要使用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对象,即事件数据。对于键名,我们使用Person
从Entity
继承的.keyName
属性构建一个字符串(它将返回类似Person:01FYC7CTPKYNXQ98JSTBC37AS1
的内容)与一个硬编码值结合。我们传入*
作为事件ID,这告诉Redis根据当前时间和之前的事件ID生成它。我们将位置——具有经度和纬度属性——作为事件数据传入。
现在,每当执行此路由时,经度和纬度将被记录,并且事件ID将编码时间。继续使用Swagger将Joan Jett移动几次。
现在,进入 Redis Insight 并查看 Stream。你会在键列表中看到它,但如果你点击它,你会收到一条消息说“此数据类型即将推出!”。如果你没有收到这条消息,恭喜你,你生活在未来!对于我们这些在过去的人来说,我们将直接发出原始命令:
XRANGE Person:01FYC7CTPKYNXQ98JSTBC37AS1:locationHistory - +
这告诉Redis从存储在给定键名中的Stream中获取一系列值——在我们的示例中是Person:01FYC7CTPKYNXQ98JSTBC37AS1:locationHistory
。接下来的值是起始事件ID和结束事件ID。-
表示Stream的开始。+
表示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"
就这样,我们正在追踪琼·杰特。
总结
所以,现在你知道如何使用 Express + Redis OM 来构建一个由 Redis Stack 支持的 API 了。而且,在这个过程中,你已经获得了一些相当不错的入门代码。干得好!如果你想了解更多,可以查看 Redis OM 的 文档。它涵盖了 Redis OM 的全部功能。
感谢您花时间完成这项工作。我真诚地希望您觉得它有用。如果您有任何问题,Redis Discord 服务器是迄今为止最好的解答场所。加入服务器并随时提问!