学习

如何使用 Redis Stack 和 NodeJS 构建社交网络应用程序

Julian Mateu
作者
Julian Mateu, Globality, Inc. 的高级后端软件工程师
Manuel Aguirre
作者
Manuel Aguirre, Baseline Spain 的后端工程师

在这篇博文中,我们将使用 Redis Stack 和 NodeJS 构建一个社交网络应用程序。这是我们为我们的应用程序 Skillmarket 使用的想法。

该应用程序的目标是将具有互补技能的用户匹配起来。它将允许用户注册并提供有关他们自己的信息,例如位置、专业领域和兴趣。使用 Redis Stack 中的搜索,它将匹配地理位置接近且具有互补专业领域和兴趣的两个用户,例如,其中一个用户懂法语并想学习吉他,而另一个用户懂吉他并想学习法语。

我们的应用程序的完整源代码可以在 GitHub 中找到(请注意,我们使用了一些功能,例如 FT.ADD 现在已弃用)

我们将使用后端的更精简版本,可以在 Skillmarket 博文 GitHub 存储库中找到。

有关 Redis Stack 中搜索的更多信息,请参阅 官方教程 。

熟悉 Redis Stack 中的搜索#

在 Docker 容器中启动 RedisStack 中的搜索#

让我们从使用 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 作为键在哈希表中创建一个用户,并将提供 expertisesinterestslocation 字段

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

我们使用 FT.CREATE 命令创建一个名为 idx:users 的全文搜索索引。我们指定 ON hash 来指示我们正在索引哈希表,并提供 PREFIX 1 "users:" 来指示我们应该索引任何键以“users:”为前缀的文档。最后,我们通过提供要索引的字段列表及其类型来指示索引的 SCHEMA

最后,我们可以使用 FT.SEARCH 命令查询索引(参见 查询语法参考

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

在 Typescript 中构建一个最小后端#

了解索引的工作原理后,让我们在 NodeJS 中构建一个最小的后端 API,它将使我们能够创建用户并查询匹配的用户。

注意

这只是一个示例,我们没有提供适当的验证或错误处理,也没有提供后端所需的其他功能(例如 身份验证)。

Redis 客户端#

我们将使用 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 函数,并从响应中构建模型对象,它以数组形式出现。结果被过滤以从匹配列表中排除当前用户。

Web API#

最后,我们可以使用 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

参考资料#