学习

Redis 地理位置搜索入门

Will Johnston
作者
Will Johnston, Redis 开发者增长经理
Prasan Kumar
作者
Prasan Kumar, Redis 技术解决方案开发者

本教程你将学到什么

在本教程中,你将获得使用 Redis 进行地理位置搜索实践知识和动手经验,特别关注其在电商平台微服务架构中的应用。以下是你将学到的内容:


  • 将地理位置搜索与 Redis 集成:深入探讨地理位置搜索的概念,探索如何利用 Redis 实现实时基于位置的搜索功能,例如附近搜索、地理空间查询和基于位置的筛选。

  • 使用 Redis 设置数据库和索引:学习设置 Redis 数据库以支持地理位置搜索的步骤,包括如何组织数据集合以及如何对其进行有效索引以实现快速高效的查询。

  • 构建和查询地理空间数据:获得编写和执行地理空间数据的原始 Redis 查询的动手经验,了解在半径内搜索、计算距离和根据地理距离排序结果可用的语法和选项。

  • 开发地理位置搜索的 API 端点:逐步了解构建利用 Redis 执行地理位置搜索的 RESTful API 端点的过程,演示如何将此功能集成到 Node.js 后端。

电商应用的微服务架构

GITHUB 代码

下面是克隆本教程中使用到的应用源代码的命令

git clone --branch v10.1.0 https://github.com/redis-developer/redis-microservices-ecommerce-solutions

让我们看看演示应用的架构

  1. 1. products service:处理从数据库查询产品并将其返回给前端
  2. 2. orders service:处理验证和创建订单
  3. 3. order history service:处理查询客户的订单历史
  4. 4. payments service:处理订单支付
  5. 5. api gateway:将服务统一到一个端点下
  6. 6. mongodb/ postgresql:作为写入优化数据库,用于存储订单、订单历史、产品等

    在演示应用中,你不必将 MongoDB/PostgreSQL 作为写入优化数据库;你也可以使用其他 prisma 支持的数据库 。这只是一个示例。

    使用 Next.js 和 Tailwind 的电商应用前端

    这个电商微服务应用包含一个前端,使用 Next.js 和 TailwindCSS 构建。应用后端使用 Node.js。数据存储在 Redis 以及 MongoDB 或 PostgreSQL 中,使用了 Prisma。下面是电商应用前端的截图。

    仪表盘:显示产品列表,具有不同的搜索功能,可在设置页面配置。


设置:
通过点击仪表盘右上角的齿轮图标进入。在此处控制搜索栏、聊天机器人可见性以及其他功能。
仪表盘(地理位置搜索):配置为地理位置搜索,搜索栏支持位置查询。
注意:在我们的演示中,每个 zipCode 位置都映射了经纬度坐标。

购物车:
将产品添加到购物车,并使用“立即购买”按钮结账。

订单历史:
购买后,顶部导航栏中的“订单”链接会显示订单状态和历史记录。

管理员面板:
通过顶部导航中的“admin”链接访问。显示购买统计数据和热门产品。

地理位置搜索涉及基于通过经纬度坐标标识的地理位置来查询和处理数据。此功能对于各种应用至关重要,包括基于位置的服务、附近搜索和空间分析。

它允许系统在地理环境中存储、索引和快速检索数据点,例如查找特定点一定距离内的所有用户,或计算两个位置之间的距离等。

Redis 的地理空间功能使开发者能够轻松构建具有位置感知功能的应用,执行附近搜索、基于位置的筛选和空间分析。

Redis 的内存架构确保地理空间数据存储在内存中并进行处理,从而实现基于位置查询的低延迟和高吞吐量。这使得 Redis 成为需要实时基于位置搜索功能的应用的理想选择。

考虑一种多门店购物场景,消费者在线查找产品,在浏览器或移动设备上提交订单,然后在最近的门店位置提货。这称为“线上购买,线下提货”(BOPIS)。Redis 支持实时查看门店库存,并提供无缝的 BOPIS 购物体验。

数据库设置

集合详情

我们的演示应用利用 Redis 中的两个主要数据集合来模拟电商平台的库存系统

  1. 1. products collection:此集合存储每个产品的详细信息,包括名称、描述、类别和价格。


  1. 2. storeInventory collection:此集合维护产品在不同门店位置的库存状态。它记录每个产品在各个门店的可用数量,便于库存跟踪和管理。

为了本次演示的目的,我们模拟了在纽约(美国)不同地区的电商运营,每个门店位置由一个storeId和产品相关的stockQty标识。

数据索引

为了在我们的 storeInventory 集合中启用地理位置搜索,对数据进行适当的索引至关重要。Redis 提供了多种创建索引的方法:使用命令行界面 (CLI) 或使用客户端库,如 Redis OM、node redis 等。

  1. 1. 使用 CLI 命令

为了方便在 storeInventory 集合上进行地理空间查询和其他搜索操作,请遵循以下命令

# Remove existing index
FT.DROPINDEX "storeInventory:storeInventoryId:index"

# Create a new index with geo-spatial and other field capabilities
FT.CREATE "storeInventory:storeInventoryId:index"
  ON JSON
  PREFIX 1 "storeInventory:storeInventoryId:"
  SCHEMA
    "$.storeId" AS "storeId" TAG SEPARATOR "|"
    "$.storeName" AS "storeName" TEXT
    "$.storeLocation" AS "storeLocation" GEO
    "$.productId" AS "productId" TAG SEPARATOR "|"
    "$.productDisplayName" AS "productDisplayName" TEXT
    "$.stockQty" AS "stockQty" NUMERIC
    "$.statusCode" AS "statusCode" NUMERIC
  1. 2. 使用 Redis OM

对于利用 Node.js 环境的应用,Redis OM 提供了一种优雅的对象映射方法来与 Redis 交互。下面是使用 Redis OM 设置索引的实现示例

server/src/common/models/store-inventory-repo.ts
// Import necessary Redis OM classes
import {
  Schema as RedisSchema,
  Repository as RedisRepository,
  EntityId as RedisEntityId,
} from 'redis-om';

import { getNodeRedisClient } from '../utils/redis/redis-wrapper';

// Define a prefix for store inventory keys and the schema for indexing
const STORE_INVENTORY_KEY_PREFIX = 'storeInventory:storeInventoryId';
const schema = new RedisSchema(STORE_INVENTORY_KEY_PREFIX, {
  storeId: { type: 'string', indexed: true },
  storeName: { type: 'text', indexed: true },
  storeLocation: { type: 'point', indexed: true }, // Uses longitude,latitude format

  productId: { type: 'string', indexed: true },
  productDisplayName: { type: 'text', indexed: true },
  stockQty: { type: 'number', indexed: true },
  statusCode: { type: 'number', indexed: true },
});

/*
 A Repository is the main interface into Redis OM. It gives us the methods to read, write, and remove a specific Entity
 */
const getRepository = () => {
  const redisClient = getNodeRedisClient();
  const repository = new RedisRepository(schema, redisClient);
  return repository;
};

/*
we need to create an index or we won't be able to search.
Redis OM uses hash to see if index needs to be recreated or not
*/
const createRedisIndex = async () => {
  const repository = getRepository();
  await repository.createIndex();
};

export {
  getRepository,
  createRedisIndex,
  RedisEntityId,
  STORE_INVENTORY_KEY_PREFIX,
};
server/src/services/products/src/index.ts
import * as StoreInventoryRepo from '../../../common/models/store-inventory-repo';

app.listen(PORT, async () => {
  //...

  // Create index for store inventory on startup
  await StoreInventoryRepo.createRedisIndex();
  //...
});

使用 Redis 构建地理位置搜索

原始查询示例

数据索引完成后,你可以执行原始 Redis 查询来执行地理位置搜索和其他空间操作。以下是两个示例查询来演示这些功能

  1. 1. 在半径内搜索产品:此示例查询演示了如何查找特定位置(纽约市)50 英里半径内包含特定产品名称(puma)的产品。它结合了地理空间搜索功能和文本搜索,根据位置和产品名称过滤结果。
FT.SEARCH "storeInventory:storeInventoryId:index" "( ( ( (@statusCode:[1 1]) (@stockQty:[(0 +inf]) ) (@storeLocation:[-73.968285 40.785091 50 mi]) ) (@productDisplayName:'puma') )"
此查询利用FT.SEARCH命令在storeInventory:storeInventoryId:index内执行搜索。它指定了一个由中心经度和纬度以及50 英里半径定义的圆形区域。此外,它还通过可用库存量(@stockQty:[(0 +inf)])和状态码@statusCode(表示活动状态)([1 1])来过滤产品,并结合产品显示名称中包含puma的匹配。

  1. 2. 用于排序结果的聚合查询
    此聚合查询在第一个示例的基础上,根据与搜索位置的地理距离对结果进行排序,并将结果限制在前 100 个。
FT.AGGREGATE "storeInventory:storeInventoryId:index"
          "( ( ( (@statusCode:[1 1]) (@stockQty:[(0 +inf]) ) (@storeLocation:[-73.968285 40.785091 50 mi]) ) (@productDisplayName:'puma') )"
          LOAD 6 "@storeId" "@storeName" "@storeLocation" "@productId" "@productDisplayName" "@stockQty"
          APPLY "geodistance(@storeLocation, -73.968285, 40.785091)/1609"
          AS "distInMiles"
          SORTBY 1 "@distInMiles"
          LIMIT 0 100
在此查询中, FT.AGGREGATE 用于处理和转换搜索结果。 APPLY 子句计算每个商店位置与指定坐标之间的距离,并将结果转换为英里。 SORTBY 子句按此距离对结果进行排序,而 LIMIT 将输出限制为 100 条,这使得该查询对于需要基于距离排序的搜索结果的应用非常有用。

API 端点

 getStoreProductsByGeoFilter API 端点允许客户端根据地理位置和产品名称搜索商店产品,展示了 Redis 地理位置搜索功能的实际应用。

API 请求

请求负载指定要搜索的产品名称、以英里为单位的搜索半径以及用户的当前位置(纬度和经度坐标)。

POST http://localhost:3000/products/getStoreProductsByGeoFilter
{
    "productDisplayName":"puma",

    "searchRadiusInMiles":50,
    "userLocation": {
        "latitude": 40.785091,
        "longitude": -73.968285
    }
}

API 响应

响应返回与搜索条件匹配的产品数组,包括每个产品的详细信息及其与用户位置的距离。

{
  "data": [
    {
      "productId": "11000",
      "price": 3995,
      "productDisplayName": "Puma Men Slick 3HD Yellow Black Watches",
      "variantName": "Slick 3HD Yellow",
      "brandName": "Puma",
      "ageGroup": "Adults-Men",
      "gender": "Men",
      "displayCategories": "Accessories",
      "masterCategory_typeName": "Accessories",
      "subCategory_typeName": "Watches",
      "styleImages_default_imageURL": "http://host.docker.internal:8080/images/11000.jpg",
      "productDescriptors_description_value": "...",

      "stockQty": "5",
      "storeId": "11_NY_MELVILLE",
      "storeLocation": {
        "longitude": -73.41512,
        "latitude": 40.79343
      },
      "distInMiles": "46.59194"
    }
    //...
  ],
  "error": null
}

API 实现

本节概述了 getStoreProductsByGeoFilter API 的实现,重点是执行核心搜索逻辑的 searchStoreInventoryByGeoFilter 函数。

  1. 1. 函数概述: searchStoreInventoryByGeoFilter 接受一个库存过滤器对象,该对象包含可选的产品显示名称、以英里为单位的搜索半径和用户位置。它构建一个查询来查找指定半径内与产品名称匹配的商店产品。

  2. 2. 查询构建:该函数使用 Redis OM 的流畅 API 构建搜索查询,指定产品可用性、库存数量以及与用户位置的距离条件。如果指定了产品名称,它还会选择性地按名称过滤产品。

  3. 3. 执行查询:构建的查询使用 ft.aggregate 方法在 Redis 上执行,该方法允许进行复杂的聚合和转换。对查询结果进行处理,以计算距离用户位置的英里距离并相应地对结果进行排序。

  4. 4. 结果处理:该函数过滤掉不同商店中的重复产品,确保最终输出中的产品列表是唯一的。然后,它将商店位置格式化为更易读的结构,并编译最终的产品列表返回。

import * as StoreInventoryRepo from '../../../common/models/store-inventory-repo';

interface IInventoryBodyFilter {
  productDisplayName?: string;

  searchRadiusInMiles?: number;
  userLocation?: {
    latitude?: number;
    longitude?: number;
  },
}

const searchStoreInventoryByGeoFilter = async (
  _inventoryFilter: IInventoryBodyFilter,
) => {
  // (1) ---
  const redisClient = getNodeRedisClient();
  const repository = StoreInventoryRepo.getRepository();
  let storeProducts: IStoreInventory[] = [];
  const trimmedStoreProducts: IStoreInventory[] = [] // similar item of other stores are removed
  const uniqueProductIds = {};

  if (repository
    && _inventoryFilter?.userLocation?.latitude
    && _inventoryFilter?.userLocation?.longitude) {

    const lat = _inventoryFilter.userLocation.latitude;
    const long = _inventoryFilter.userLocation.longitude;
    const radiusInMiles = _inventoryFilter.searchRadiusInMiles || 500;

    // (2) --- Query Construction
    let queryBuilder = repository
      .search()
      .and('statusCode')
      .eq(1)
      .and('stockQty')
      .gt(0)
      .and('storeLocation')
      .inRadius((circle) => {
        return circle
          .latitude(lat)
          .longitude(long)
          .radius(radiusInMiles)
          .miles
      });

    if (_inventoryFilter.productDisplayName) {
      queryBuilder = queryBuilder
        .and('productDisplayName')
        .matches(_inventoryFilter.productDisplayName)
    }

    console.log(queryBuilder.query);

    /* Sample queryBuilder.query to run on CLI
    FT.SEARCH "storeInventory:storeInventoryId:index" "( ( ( (@statusCode:[1 1]) (@stockQty:[(0 +inf]) ) (@storeLocation:[-73.968285 40.785091 50 mi]) ) (@productDisplayName:'puma') )"
            */

   // (3) --- Executing the Query
    const indexName = `storeInventory:storeInventoryId:index`;
    const aggregator = await redisClient.ft.aggregate(
      indexName,
      queryBuilder.query,
      {
        LOAD: ["@storeId", "@storeName", "@storeLocation", "@productId", "@productDisplayName", "@stockQty"],
        STEPS: [{
          type: AggregateSteps.APPLY,
          expression: `geodistance(@storeLocation, ${long}, ${lat})/1609`,
          AS: 'distInMiles'
        }, {
          type: AggregateSteps.SORTBY,
          BY: ["@distInMiles", "@productId"]
        }, {
          type: AggregateSteps.LIMIT,
          from: 0,
          size: 1000,
        }]
      });

    /* Sample command to run on CLI
        FT.AGGREGATE "storeInventory:storeInventoryId:index"
          "( ( ( (@statusCode:[1 1]) (@stockQty:[(0 +inf]) ) (@storeLocation:[-73.968285 40.785091 50 mi]) ) (@productDisplayName:'puma') )"
          "LOAD" "6" "@storeId" "@storeName" "@storeLocation" "@productId" "@productDisplayName" "@stockQty"
          "APPLY" "geodistance(@storeLocation, -73.968285, 40.785091)/1609"
          "AS" "distInMiles"
          "SORTBY" "1" "@distInMiles"
          "LIMIT" "0" "100"
    */

    storeProducts = <IStoreInventory[]>aggregator.results;

    if (!storeProducts.length) {
      // throw `Product not found with in ${radiusInMiles}mi range!`;
    }
    else {

     // (4) --- Result Processing
      storeProducts.forEach((storeProduct) => {
        if (storeProduct?.productId && !uniqueProductIds[storeProduct.productId]) {
          uniqueProductIds[storeProduct.productId] = true;

          if (typeof storeProduct.storeLocation == "string") {
            const location = storeProduct.storeLocation.split(",");
            storeProduct.storeLocation = {
              longitude: Number(location[0]),
              latitude: Number(location[1]),
            }
          }

          trimmedStoreProducts.push(storeProduct)
        }
      });
    }
  }
  else {
    throw "Mandatory fields like userLocation latitude / longitude missing !"
  }

  return {
    storeProducts: trimmedStoreProducts,
    productIds: Object.keys(uniqueProductIds)
  };
};

该实现展示了 Redis 地理位置搜索功能的实际用例,展示了如何执行基于距离的搜索并结合其他过滤条件(如产品名称),以及如何以用户友好的格式呈现结果。

前端

请确保在设置页面中选择 Geo location search 以启用该功能。

在仪表板内,用户可以选择一个随机邮政编码并搜索产品(例如,名为“puma”的产品)。搜索结果会全面显示,包括产品名称、可用库存数量、商店名称以及商店与用户选定位置的距离等基本详细信息。

Redis 的地理位置搜索功能提供了一种强大而高效的方式来执行基于距离的查询和分析。

通过利用 Redis 的内存数据存储和专门的地理命令,开发人员可以构建可扩展、高性能的应用,这些应用能够快速响应基于位置的查询。与 JavaScript 生态系统的集成进一步简化了开发过程,实现了无缝的应用开发和部署。