在本教程中,你将获得使用 Redis 进行地理位置搜索实践知识和动手经验,特别关注其在电商平台微服务架构中的应用。以下是你将学到的内容:
下面是克隆本教程中使用到的应用源代码的命令
git clone --branch v10.1.0 https://github.com/redis-developer/redis-microservices-ecommerce-solutions
让我们看看演示应用的架构
1. products service
:处理从数据库查询产品并将其返回给前端2. orders service
:处理验证和创建订单3. order history service
:处理查询客户的订单历史4. payments service
:处理订单支付5. api gateway
:将服务统一到一个端点下6. mongodb/ postgresql
:作为写入优化数据库,用于存储订单、订单历史、产品等这个电商微服务应用包含一个前端,使用 Next.js 和 TailwindCSS 构建。应用后端使用 Node.js。数据存储在 Redis 以及 MongoDB 或 PostgreSQL 中,使用了 Prisma。下面是电商应用前端的截图。
仪表盘:显示产品列表,具有不同的搜索功能,可在设置页面配置。
地理位置搜索
,搜索栏支持位置查询。地理位置搜索涉及基于通过经纬度坐标标识的地理位置来查询和处理数据。此功能对于各种应用至关重要,包括基于位置的服务、附近搜索和空间分析。
它允许系统在地理环境中存储、索引和快速检索数据点,例如查找特定点一定距离内的所有用户,或计算两个位置之间的距离等。
Redis 的地理空间功能使开发者能够轻松构建具有位置感知功能的应用,执行附近搜索、基于位置的筛选和空间分析。
Redis 的内存架构确保地理空间数据存储在内存中并进行处理,从而实现基于位置查询的低延迟和高吞吐量。这使得 Redis 成为需要实时基于位置搜索功能的应用的理想选择。
考虑一种多门店购物场景,消费者在线查找产品,在浏览器或移动设备上提交订单,然后在最近的门店位置提货。这称为“线上购买,线下提货”(BOPIS)。Redis 支持实时查看门店库存,并提供无缝的 BOPIS 购物体验。
我们的演示应用利用 Redis 中的两个主要数据集合来模拟电商平台的库存系统
1. products
collection:此集合存储每个产品的详细信息,包括名称、描述、类别和价格。2. storeInventory
collection:此集合维护产品在不同门店位置的库存状态。它记录每个产品在各个门店的可用数量,便于库存跟踪和管理。storeId
和产品相关的stockQty
标识。为了在我们的 storeInventory 集合中启用地理位置搜索,对数据进行适当的索引至关重要。Redis 提供了多种创建索引的方法:使用命令行界面 (CLI) 或使用客户端库,如 Redis OM、node redis 等。
为了方便在 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
对于利用 Node.js 环境的应用,Redis OM 提供了一种优雅的对象映射方法来与 Redis 交互。下面是使用 Redis OM 设置索引的实现示例
// 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,
};
import * as StoreInventoryRepo from '../../../common/models/store-inventory-repo';
app.listen(PORT, async () => {
//...
// Create index for store inventory on startup
await StoreInventoryRepo.createRedisIndex();
//...
});
数据索引完成后,你可以执行原始 Redis 查询来执行地理位置搜索和其他空间操作。以下是两个示例查询来演示这些功能
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
的匹配。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 条,这使得该查询对于需要基于距离排序的搜索结果的应用非常有用。 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
}
本节概述了 getStoreProductsByGeoFilter
API 的实现,重点是执行核心搜索逻辑的 searchStoreInventoryByGeoFilter
函数。
1. 函数概述: searchStoreInventoryByGeoFilter
接受一个库存过滤器对象,该对象包含可选的产品显示名称、以英里为单位的搜索半径和用户位置。它构建一个查询来查找指定半径内与产品名称匹配的商店产品。
2. 查询构建:该函数使用 Redis OM 的流畅 API 构建搜索查询,指定产品可用性、库存数量以及与用户位置的距离条件。如果指定了产品名称,它还会选择性地按名称过滤产品。
3. 执行查询:构建的查询使用 ft.aggregate 方法在 Redis 上执行,该方法允许进行复杂的聚合和转换。对查询结果进行处理,以计算距离用户位置的英里距离并相应地对结果进行排序。
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
以启用该功能。
Redis 的地理位置搜索功能提供了一种强大而高效的方式来执行基于距离的查询和分析。
通过利用 Redis 的内存数据存储和专门的地理命令,开发人员可以构建可扩展、高性能的应用,这些应用能够快速响应基于位置的查询。与 JavaScript 生态系统的集成进一步简化了开发过程,实现了无缝的应用开发和部署。