在这篇博文中,我们将使用 Redis Stack 和 NodeJS 构建一个社交网络应用程序。这是我们为我们的应用程序 Skillmarket 使用的想法。
该应用程序的目标是将具有互补技能的用户匹配起来。它将允许用户注册并提供有关他们自己的信息,例如位置、专业领域和兴趣。使用 Redis Stack 中的搜索,它将匹配地理位置接近且具有互补专业领域和兴趣的两个用户,例如,其中一个用户懂法语并想学习吉他,而另一个用户懂吉他并想学习法语。
我们的应用程序的完整源代码可以在 GitHub 中找到(请注意,我们使用了一些功能,例如 FT.ADD 现在已弃用)
我们将使用后端的更精简版本,可以在 Skillmarket 博文 GitHub 存储库中找到。
有关 Redis Stack 中搜索的更多信息,请参阅 官方教程 。
让我们从使用 Docker 启动 Redis Stack 镜像中的 Redis 开始
docker run -d --name redis redis/redis-stack:latest
在这里,我们使用 docker run
命令启动容器并拉取镜像(如果不存在)。-d
标志告诉 docker 在后台(分离模式)启动容器。我们使用 --name redis
提供一个名称,这将使我们能够使用友好的名称来引用此容器,而不是 docker 将分配的哈希或随机名称。
最后,redislabs/readisearch:latest
告诉 docker 使用 redislabs/readisearch 镜像
的 latest
版本。
镜像启动后,我们可以使用 docker exec
在容器内启动一个终端,使用 -it
标志(交互式 tty)并指定之前创建镜像时提供的 redis
名称,以及 bash
命令
docker exec -it redis bash
进入容器后,让我们启动一个 redis-cli
实例来熟悉 CLI
redis-cli
您会注意到提示符现在指示我们已连接到 127.0.0.1:6379
我们将使用 Hash 作为数据结构来存储有关我们用户的信息。这将是一个概念验证,因此我们的应用程序只会将 Redis 用作数据存储。对于现实场景,可能最好有一个主要数据存储,它作为用户数据的权威来源,并将 Redis 用作搜索索引,可以用来加快搜索速度。
简而言之,您可以将哈希视为键值存储,其中键可以是任何我们想要的字符串,而值是具有多个字段的文档。通常的做法是在哈希中存储许多不同类型的对象,以便可以为它们添加类型前缀,因此键将采用“object_type:id”的形式。
然后将使用索引来索引此哈希数据结构,以有效地搜索给定字段的值。以下摘自搜索文档的图表用电影数据库对此进行了说明
使用 help @hash
命令(或参考 文档)获取可用于操作哈希的命令列表。要获取单个命令的帮助,例如 HSET
,让我们键入 help HSET
127.0.0.1:6379> help hset
HSET key field value [field value ...]
summary: Set the string value of a hash field
since: 2.0.0
group: hash
正如我们所见,我们可以提供一个键和一个 field value
对列表。
我们将使用 user:id
作为键在哈希表中创建一个用户,并将提供 expertises
、interests
和 location
字段
HSET users:1 name "Alice" expertises "piano, dancing" interests "spanish, bowling" location "2.2948552,48.8736537"
HSET users:2 name "Bob" expertises "french, spanish" interests "piano" location "2.2945412,48.8583206"
HSET users:3 name "Charles" expertises "spanish, bowling" interests "piano, dancing" location "-0.124772,51.5007169"
FT.CREATE idx:users ON hash PREFIX 1 "users:" SCHEMA interests TAG expertises TAG location GEO
127.0.0.1:6379> FT.SEARCH idx:users "@interests:{dancing|piano} @expertises:{spanish|bowling} @location:[2.2948552 48.8736537 5 km]"
1) (integer) 1
2) "users:2"
3) 1) "name"
2) "Bob"
3) "expertises"
4) "french, spanish"
5) "interests"
6) "piano"
7) "location"
8) "2.2945412,48.8583206"
在本例中,我们正在寻找与 Alice 匹配的内容,因此我们在查询的 interests
字段中使用她的专业技能,在 expertises
字段中使用她的兴趣。我们还搜索了距她位置 5 公里半径内的用户,并获得了 Bob 作为匹配项。
如果我们将搜索半径扩展到 500 公里,我们还将看到 Charles 被返回
127.0.0.1:6379> FT.SEARCH idx:users "@interests:{dancing|piano} @expertises:{spanish|bowling} @location:[2.2948552 48.8736537 500 km]"
1) (integer) 2
2) "users:3"
3) 1) "name"
2) "Charles"
3) "expertises"
4) "spanish, bowling"
5) "interests"
6) "piano, dancing"
7) "location"
8) "-0.124772,51.5007169"
4) "users:2"
5) 1) "name"
2) "Bob"
3) "expertises"
4) "french, spanish"
5) "interests"
6) "piano"
7) "location"
8) "2.2945412,48.8583206"
我们现在可以删除 docker 实例并继续构建 Web 应用程序,从实例外部运行以下命令
docker rm -f redis
了解索引的工作原理后,让我们在 NodeJS 中构建一个最小的后端 API,它将使我们能够创建用户并查询匹配的用户。
这只是一个示例,我们没有提供适当的验证或错误处理,也没有提供后端所需的其他功能(例如 身份验证)。
我们将使用 node-redis 包来创建客户端:
const {
REDIS_PORT = 6379,
REDIS_HOST = 'localhost',
} = process.env;
const client: RediSearchClient = createClient({
port: Number(REDIS_PORT),
host: REDIS_HOST,
});
库中的所有函数都使用回调,但我们可以使用 promisify
来启用 async/await
语法
client.hgetallAsync = promisify(client.hgetall).bind(client);
client.hsetAsync = promisify(client.hset).bind(client);
client.ft_createAsync = promisify(client.ft_create).bind(client);
client.ft_searchAsync = promisify(client.ft_search).bind(client);
最后,让我们定义一个函数来创建用户索引,就像我们在 CLI 示例中之前做的那样
async function createUserIndex() {
client.ft_createAsync(
'idx:users',
['ON', 'hash', 'PREFIX', '1', 'users:', 'SCHEMA', 'interests', 'TAG', 'expertises', 'TAG', 'location', 'GEO']
);
}
让我们定义控制器将用来在 Redis 之上公开一个简单 API 的函数。我们将定义 3 个函数: - findUserById(userId)
- createUser(user)
- findMatchesForUser(user)
但首先让我们定义我们将用于用户的模型
interface Location {
latitude: number;
longitude: number;
};
interface User {
id?: string;
name: string;
interests: string[];
expertises: string[];
location: Location
};
让我们从使用模型对象创建用户的函数开始
async function createUser(user: User): Promise<string> {
const id = uuid();
redisearchClient.hsetAsync(`users:${id}`, _userToSetRequestString(user));
return id;
}
function _userToSetRequestString(user: User): string[] {
const { id, location, interests, expertises, ...fields } = user;
let result = Object.entries(fields).flat();
result.push('interests', interests.join(', '));
result.push('expertises', expertises.join(', '));
result.push('location', `${location.longitude},${location.latitude}`);
return result;
}
我们将为用户创建一个 UUID,然后将 TAG 和 GEO 字段转换为 Redis 格式。以下是如何看起来的两个格式的示例
现在让我们看一下使用 HGETALL
从哈希表中检索现有用户的逻辑:
async function findUserById(userId: string): Promise<User> {
const response = await redisearchClient.hgetallAsync(`users:${userId}`);
if (!response) {
throw new Error('User Not Found');
}
return _userFromFlatEntriesArray(userId, Object.entries(response).flat());
}
function _userFromFlatEntriesArray(id: string, flatEntriesArray: any[]): User {
let user: any = {};
// The flat entries array contains all keys and values as elements in an array, e.g.:
// [key1, value1, key2, value2]
for (let j = 0; j < flatEntriesArray.length; j += 2) {
let key: string = flatEntriesArray[ j ];
let value: string = flatEntriesArray[ j + 1 ];
user[ key ] = value;
}
const location: string[] = user.location.split(',');
user.location = { longitude: Number(location[ 0 ]), latitude: Number(location[ 1 ]) };
user.expertises = user.expertises.split(', ');
user.interests = user.interests.split(', ');
return {id, ...user};
}
这里我们有反向逻辑,我们想要将 TAG 和 GEO 字段拆分为模型对象。还有一个事实是 HGETALL
将字段名和值返回到数组中,我们需要从该数组中构建模型对象。
最后让我们看一下查找给定用户的匹配项的逻辑
async function findMatchesForUser(user: User, radiusKm: number): Promise<User[]> {
const allMatches: User[] = await _findMatches(user.interests, user.expertises, user.location, radiusKm);
return allMatches.filter(u => u.id !== user.id);
}
async function _findMatches(expertises: string[], interests: string[], location: Location, radiusKm: number): Promise<User[]> {
let query = `@interests:{${interests.join('|')}}`
query += ` @expertises:{${expertises.join('|')}}`
query += ` @location:[${location.longitude} ${location.latitude} ${radiusKm} km]`;
const response = await redisearchClient.ft_searchAsync('idx:users', query);
return _usersFromSearchResponseArray(response);
}
function _usersFromSearchResponseArray(response: any[]): User[] {
let users = [];
// The search response is an array where the first element indicates the number of results, and then
// the array contains all matches in order, one element is they key and the next is the object, e.g.:
// [2, key1, object1, key2, object2]
for (let i = 1; i <= 2 * response[ 0 ]; i += 2) {
const user: User = _userFromFlatEntriesArray(response[ i ].replace('users:', ''), response[ i + 1 ]);
users.push(user);
}
return users;
}
在这里,我们交换兴趣和专业知识以找到互补的技能组合,并构建我们在 CLI 示例中之前使用的查询。我们最终调用了 FT.SEARCH
函数,并从响应中构建模型对象,它以数组形式出现。结果被过滤以从匹配列表中排除当前用户。
最后,我们可以使用 express 构建一个简单的 Web API,公开一个 POST /users
端点来创建用户,一个 GET /users/:userId
端点来检索用户,以及一个 GET /users/:userId/matches
端点来查找给定用户的匹配项(所需的 radiusKm
可以作为查询参数可选地指定)
app.post('/users', async (req, res) => {
const user: User = req.body;
if (!user || !user.name || !user.expertises || !user.interests || user.location.latitude === undefined || user.location.longitude === undefined) {
res.status(400).send('Missing required fields');
} else {
const userId = await userController.createUser(user);
res.status(200).send(userId);
}
});
app.get("/users/:userId", async (req, res) => {
try {
const user: User = await userController.findUserById(req.params.userId);
res.status(200).send(user);
} catch (e) {
res.status(404).send();
}
});
app.get("/users/:userId/matches", async (req, res) => {
try {
const radiusKm: number = Number(req.query.radiusKm) || 500;
const user: User = await userController.findUserById(req.params.userId);
const matches: User[] = await userController.findMatchesForUser(user, radiusKm);
res.status(200).send(matches);
} catch (e) {
console.log(e)
res.status(404).send();
}
});
在本博文中使用的代码可以在 GitHub 仓库中找到。可以使用 docker compose 启动后端以及 redis:
docker compose up -d --build
后端 API 将在端口 8080
上公开。我们可以使用 docker compose
logs 查看日志,并使用客户端查询它。以下是如何使用 httpie 的示例:
http :8080/users \
name="Alice" \
expertises:='["piano", "dancing"]' \
interests:='["spanish", "bowling"]' \
location:='{"longitude": 2.2948552, "latitude": 48.8736537}'
----------
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 36
Content-Type: text/html; charset=utf-8
Date: Mon, 01 Nov 2021 05:24:52 GMT
ETag: W/"24-dMinMMphAGzfWiCs49RBYnyK+r8"
Keep-Alive: timeout=5
X-Powered-By: Express
03aef405-ef37-4254-ab3c-a5ddfbc4f04e
http ":8080/users/03aef405-ef37-4254-ab3c-a5ddfbc4f04e/matches?radiusKm=15"
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 174
Content-Type: application/json; charset=utf-8
Date: Mon, 01 Nov 2021 05:26:29 GMT
ETag: W/"ae-3k2/swmuFaJd7BNHrkgvS/S+h2g"
Keep-Alive: timeout=5
X-Powered-By: Express
[
{
"expertises": [
"french",
" spanish"
],
"id": "58e81f09-d9fa-4557-9b8f-9f48a9cec328",
"interests": [
"piano"
],
"location": {
"latitude": 48.8583206,
"longitude": 2.2945412
},
"name": "Bob"
}
]
最后清理环境
docker compose down --volumes --remove-orphans