在这篇博文中,我们将使用 Redis Stack 和 NodeJS 构建一个社交网络应用。这是我们用于应用 Skillmarket 的创意。
这个应用的目标是匹配拥有互补技能的用户。它将允许用户注册并提供一些关于自己的信息,如位置、专业领域和兴趣。通过 Redis Stack 中的搜索功能,它将匹配地理位置相近且拥有互补专业领域和兴趣的两位用户,例如,其中一人会法语并想学吉他,另一人会吉他并想学法语。
我们的应用的完整源代码可以在 GitHub 上找到(请注意,我们使用了一些现已弃用的功能,如 FT.ADD)。
我们将使用一个更精简的后端版本,该版本可以在 Skillmarket Blogpost 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
标志(交互式终端)并指定之前创建镜像时提供的 redis
名称和 bash
命令。
docker exec -it redis bash
进入容器后,让我们启动一个 redis-cli
实例来熟悉 CLI
redis-cli
您会注意到提示符现在显示我们已连接到 127.0.0.1:6379
我们将使用哈希作为数据结构来存储用户信息。这将是一个概念验证,因此我们的应用仅使用 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
如我们所见,我们可以提供一个键和一对 字段值
对列表。
我们将使用 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 仓库 中找到。后端和 Redis 可以使用 docker compose 启动:
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