学习

Redis 地理位置搜索入门

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

本教程中您将学到的内容

在本教程中,您将获得使用 Redis 的地理位置搜索功能的实际知识和实践经验,尤其侧重于其在电子商务平台的微服务架构中的应用。以下内容是您期望学到的内容


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

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

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

  • 为地理位置搜索开发 API 端点:逐步完成构建 RESTful API 端点的过程,该端点利用 Redis 执行地理位置搜索,演示如何将此功能集成到 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。以下是展示电子商务应用程序前端的屏幕截图。

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


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

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

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

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

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

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

Redis 的 地理空间 功能使开发人员能够轻松构建具有位置感知功能的应用程序,这些应用程序可以执行附近搜索、基于位置的过滤和空间分析。

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

考虑一个多店购物场景,消费者在线上找到产品,在浏览器或移动设备中下订单,并在最近的商店位置提货。这被称为“在线购买,店内提货”(BOPIS)。Redis 使商店库存的 实时视图 和无缝的 BOPIS 购物体验成为可能。

数据库设置

集合详细信息

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

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


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

为了演示的目的,我们模拟了在美国纽约各地区的电子商务运营,每个商店位置都由一个 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 https://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": "https://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 地理位置搜索功能的实际用例,展示了如何执行与其他过滤条件(如产品名称)相结合的邻近搜索,并将结果以用户友好的格式呈现。

前端

确保在设置页面中选择 地理位置搜索 以启用该功能。

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

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

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