学习

如何使用 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 Blogpost 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 标志(交互式终端)并指定之前创建镜像时提供的 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 作为键在哈希表中创建一个用户,并提供 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 仓库 中找到。后端和 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

参考资料#