由 RediSearch 提供支持的 RedisJSON 现已公开预览。在本篇博文中,我们将通过介绍如何使用 RedisJSON 来构建 RedisMart 的产品目录服务,深入探讨如何开始使用 RedisJSON 新的 JSON 索引、查询和全文搜索功能。如果您错过了,RedisMart 是我们在 RedisConf 2021 主题演讲中演示的全功能实时零售商店。我们还发表了一篇博文,深入探讨了 RedisMart 零售应用程序的主要需求和架构。
RedisJSON 是一款基于 Redis 开源构建的高性能文档存储,以源代码可用许可证提供。其主要目标是让您能够使用应用程序中可能已经在使用的 JSON 对象,并在 Redis 中访问和搜索它们。它还提供比简单哈希 API 更复杂的数据 API,允许您在无需使用多个键的情况下扩展数据模型。
您可以通过以下几种方式开始使用 RedisJSON:
对于本篇文章,我们将使用 Docker 容器选项
$ docker run -d -p 6379:6379 redislabs/rejson:preview
运行后,您可以使用 redis-cli 连接到它
$ redis-cli json.set foo . '{"hello" : "json"}' OK $ redis-cli json.get foo hello "\"json\""
RedisMart 是使用 Python 构建的,因此我们将使用最近简化的开发者工具进行连接。
在 Redis,我们倾向于使用 Poetry 进行依赖项管理,但将 redis-py 添加到应用程序的过程大致相同
poetry add redis
或者,当然
pip install redis
将 redis-py 添加到 Python 环境后,我们可以连接到 Docker 容器并运行一个简单的“hello world”应用程序
from redis import Redis R = Redis() R.json().set('key', '.', {'hello':'json'}) print(R.json().get('key'))
现在我们已经准备好了基础知识,让我们创建数据模型和索引。
套用伟大的卡尔·萨根的话来说,如果你想从头开始创建一个 RedisJSON 产品目录服务,你必须首先创建一个搜索索引。对于 RedisMart,我们使用了一个现实但直接的数据模型。(请参阅此处的完整 Gist。)
SAMPLE_DATA = [{'SKU': '43214563', 'categories': 'electronics,headphones', 'long_description': 'Monocle ipsum dolor sit amet k-pop smart bespoke, alluring wardrobe espresso Zürich charming Nordic ANA destination elegant bureaux handsome Melbourne. ', 'name': 'Craniumcandy - Wireless Over-the-Ear Headphones - Crazy Tropical', 'price': 100, 'rating': 1, 'short_description': 'Toto, K-pop sharp the highest quality sleepy boutique joy.'}, {'SKU': '431553432', 'name': 'Doodle - Sprite Buds True Wireless In-Ear Headphones - Night Black', 'short_description': 'Beams the highest quality remarkable Swiss concierge. Cosy signature the best extraordinary.', 'long_description': 'Discerning airport first-class, elegant conversation artisanal Beams flat white ryokan Helsinki Boeing 787 K-pop concierge soft power iconic. Toto Melbourne pintxos, joy destination global craftsmanship St Moritz smart premium boutique. Boeing 787 premium first-class extraordinary the best Zürich discerning elegant. Charming impeccable emerging sophisticated international Airbus A380 efficient Beams cosy Marylebone Muji Asia-Pacific. Charming uniforms Beams airport, essential Zürich global Nordic extraordinary Boeing 787 iconic vibrant.', 'price': 180, 'rating': 5, 'categories': 'electronics,headphones'}, {'SKU': '8743153432', 'name': 'Doodle - Sprite 6 Expert', 'short_description': 'Beams the highest quality remarkable Swiss concierge. Cosy signature the best extraordinary.', 'long_description': 'Discerning airport first-class, elegant conversation artisanal Beams flat white ryokan Helsinki Boeing 787 K-pop concierge soft power iconic. Toto Melbourne pintxos, joy destination global craftsmanship St Moritz smart premium boutique. Boeing 787 premium first-class extraordinary the best Zürich discerning elegant. Charming impeccable emerging sophisticated international Airbus A380 efficient Beams cosy Marylebone Muji Asia-Pacific. Charming uniforms Beams airport, essential Zürich global Nordic extraordinary Boeing 787 iconic vibrant.', 'price': 899, 'rating': 5, 'categories': 'cell phone,electronics'}, {'SKU': '4316647899', 'name': 'Blues Banjo Songs for Noobs - Gal Nimoy', 'short_description': 'The best Boeing 787 Lufthansa Toto. Destination Singapore efficient Nordic craftsmanship.', 'long_description': 'Wardrobe Fast Lane exclusive perfect delightful extraordinary Melbourne K-pop classic Airbus A380 elegant the highest quality. Emerging boutique concierge quality of life finest, punctual elegant delightful pintxos airport tote bag Muji flat white Swiss.', 'price': 23, 'rating': 3, 'categories': 'books,music'}, {'SKU': '84836424542', 'name': 'Be Here Now - Richard Alpert', 'short_description': 'The best Boeing 787 Lufthansa Toto. Destination Singapore efficient Nordic craftsmanship.', 'long_description': 'Wardrobe Fast Lane exclusive perfect delightful extraordinary Melbourne K-pop classic Airbus A380 elegant the highest quality. Emerging boutique concierge quality of life finest, punctual elegant delightful pintxos airport tote bag Muji flat white Swiss.', 'price': 42, 'rating': 3, 'categories': 'books'}]
使用它并创建索引非常简单
definition = IndexDefinition(prefix=[PRODUCTS_KEY.format('')], index_type=IndexType.JSON) ## Categories implemented as Tags - allows for more complex searching ctg_field = TagField('$.categories', as_name='categories') ctg_field_params = list(ctg_field.args) ctg_field.args = tuple(ctg_field_params) ## actually create the index client.create_index(( TagField("$.SKU", as_name='SKU'), TextField("$.name", as_name='name'), TextField("$.short_description", as_name='short_description'), TextField("$.long_description", as_name='long_description'), NumericField("$.price", sortable=True, as_name='price'), NumericField("$.rating", sortable=True, as_name='rating'), ctg_field), definition=definition)
请注意,对于每个字段,我们都使用 as_name 参数为完整路径设置别名。这有两个好处:一是在查询中使用该属性时,无需指定完整路径。二是它允许您更改该属性的底层路径,而无需更改调用它的代码。
创建索引后,您将使用相同的对象来搜索它。最佳做法是首先使用 .info() 方法检查索引是否已创建,如果未创建,则创建它。让我们扩展搜索索引创建代码,以添加这些情况
client = SearchClient(PRODUCTS_INDEX_KEY, conn=R) try: client.info() return client except ResponseError: print("index doesn't exist, creating")
使用 RedisJSON 的一个好处是,您可以使用 FT.ALTER 命令轻松地向索引添加新字段。
接下来,我们将使应用程序的其余部分可以访问此搜索功能。
该项目的一项要求是,我们能够使用不同的属性查询产品目录。按产品名称查询是一个显而易见的选择,但我们也实现了按价格、评级和类别进行过滤。在分面导航菜单中,您可以使用它快速找到您要查找的内容。
类别也显示在由 RedisJSON 的模糊搜索功能提供支持的自动完成下拉菜单中
使用“建议”功能可以轻松完成模糊搜索,我们可以将该功能添加到添加到目录中的任何数据中
def add_products(): ''' Ingests the sample data into the DB ''' with R.pipeline(transaction=False) as pipeline: for product in SAMPLE_DATA: key = PRODUCTS_KEY.format(product['SKU']) pipeline.jsonset(key, Path.rootPath(), product) ac = AutoCompleter(AUTOCOMPLETE_KEY, conn=pipeline) categories = product['categories'].split(',') suggestions = [Suggestion(product['name'])] suggestions.extend([Suggestion(category) for category in categories]) ac.add_suggestions(*suggestions) pipeline.execute()
现在我们已经设置好了索引和建议,让我们为产品微服务构建一个搜索查询函数
def search_products(name=None, categories=None, price=None, rating=None, fields=None, paging=None, sort_by='name', ascending=True, **kwargs): ''' Search the product catalog name - name of the product categories - comma delimited category list (category1,category2) price - price range, hyphen delimited (price_low-price_high) rating - rating range, hyphen delmited (rating_low-rating_high) fields - object fields to return in response paging - comma delimited, show item range in this page (paging_start,paging_end) sort_by - field to sort by ascending - ascending or descending ''' ## for non-numeric search terms, we'll build the term query search_terms = [] if name: search_terms.append(f'@name:{name}') if categories: _categories = categories.split(',') search_terms.append(f'@categories:{{{"|".join(_categories)}}}') ## tag search uses {} vs () for compounds if search_terms: ## join the non-numeric terms together to create the query object terms_query = ' '.join(search_terms) else: ## no non-numeric ones were set, wildcard terms_query = '*' ## create and configure query object query = Query(terms_query) query.sort_by(sort_by, asc=ascending) if paging is not None: query.paging(*paging.split(',')) if fields is not None: query.return_fields(*fields.split(',')) ## numeric terms and other query parameters if price: price_low, price_high = price.split('-') query.add_filter(NumericFilter('price', price_low, price_high)) if rating: rating_low, rating_high = rating.split('-') query.add_filter(NumericFilter('rating', rating_low, rating_high)) ## execute search result = product_index().search(query) return [loads(doc.json) for doc in result.docs] # in order to get these all into one json obj, we'll need to transerialze them
最后但并非最不重要的一点是,我们需要添加从应用程序的其余部分添加和修改项目的功能。
对于 RedisMart,我们将产品目录放在微服务中。
为了完成 REST API,我们需要添加创建、更新和删除流程。
ALLOWED_FIELDS = ['SKU', 'name', 'categories', 'short_description', 'long_description', 'price', 'rating'] @app.route('/') def get_search(): ''' Search the product catalog URL Args: name - name of the product categories - comma delimited category list (category1,category2) price - price range, hyphen delimited (price_low-price_high) rating - rating range, hyphen delmited (rating_low-rating_high) fields - object fields to return in response paging - tuple of item range to show in this page (beginning, end) sort_by - field to sort by ascending - ascending or descending ''' return jsonify(search_products(**request.args)) @app.route('/suggestions/<string:stem>') def get_suggestions(stem): ''' Get autocomplete suggestions for a given stem ''' return jsonify(get_suggestions(escape(stem))) @app.route('/product', methods=['POST']) def post_product(): ''' HTTP POST request to create/update a product ''' product = {} for k, v in request.form.items(): if k not in ALLOWED_FIELDS: return f'{k} not allowed in product POST request', 403 product[k] = v key = PRODUCTS_KEY.format(product['SKU']) R.jsonset(key, Path.rootPath(), product) return Response(status=200) @app.route('/product/<string:sku>') def get_product(sku): ''' Get the JSON object for a product from its SKU ''' key = PRODUCTS_KEY.format(sku) return jsonify(R.jsonget(key)) @app.route('/product/<string:sku>', methods=['DELETE']) def delete_product(sku): ''' Remove a product from the catalog ''' key = PRODUCTS_KEY.format(sku) key_deleted = R.delete(key) if key_deleted: return Response(status=200) else: return Response(status=404)
到目前为止,您已经了解了如何轻松地获取基于 JSON 的产品数据,并通过使用微服务使其可搜索并可供任何现代应用程序访问。除了对 JSON 文档进行索引、搜索和全文搜索功能外,由 RediSearch 提供支持的 RedisJSON 还包括强大的 数据聚合 功能(请参阅 教程 和 在线课程)。以下是一些入门链接
Prerequisites:
Python 3.9+
Redis & RedisJSON 2.0
To run:
## if you want to run the container locally
docker run -d -p 6379:6379 redislabs/rejson:preview
pip3 install poetry
poetry install
poetry run python3 redismart_product_catalog.py
Access by navigating to
https://#:5000
[tool.poetry]
name = "redismart_product_catalog"
version = "0.1.0"
description = "Accompanying Code for RedisMart Blog Pt. 2"
authors = []
license = "BSD"
[tool.poetry.dependencies]
python = "^3.9"
redis = "^3.5.3"
rejson = "^0.5.4"
redisearch = {git = "https://github.com/RediSearch/redisearch-py", rev = "master"}
Flask = "^2.0.2"
[tool.poetry.dev-dependencies]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
# Copyright 2021 Redis Ltd.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation and/or
# other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
# IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
from json import JSONDecoder, loads
from flask import Flask, request, Response, escape, jsonify
from rejson import Client, Path
from redis import ResponseError
from redisearch.client import IndexType
from redisearch import (Client as SearchClient,
NumericField,
TextField,
TagField,
IndexDefinition,
Suggestion,
AutoCompleter,
NumericFilter,
Query)
class RedisJsonDecoder(JSONDecoder):
def decode(self, s, *args, **kwargs):
if isinstance(s, bytes):
s = s.decode('UTF-8')
elif isinstance(s, list):
return s[0]
return super(RedisJsonDecoder, self).decode(s, *args, **kwargs)
R = Client(decode_responses=True,
encoding_errors='ignore',
decoder=RedisJsonDecoder())
assert R.ping()
app = Flask(__name__)
#########
## API ##
#########
ALLOWED_FIELDS = ['SKU', 'name', 'categories', 'short_description', 'long_description', 'price', 'rating']
@app.route('/')
def get_search():
'''
Search the product catalog
URL Args:
name - name of the product
categories - comma delimited category list (category1,category2)
price - price range, hyphen delimited (price_low-price_high)
rating - rating range, hyphen delmited (rating_low-rating_high)
fields - object fields to return in response
paging - tuple of item range to show in this page (beginning, end)
sort_by - field to sort by
ascending - ascending or descending
'''
return jsonify(search_products(**request.args))
@app.route('/suggestions/<string:stem>')
def get_suggestions(stem):
'''
Get autocomplete suggestions for a given stem
'''
return jsonify(get_suggestions(escape(stem)))
@app.route('/product', methods=['POST'])
def post_product():
'''
HTTP POST request to create/update a product
'''
product = {}
for k, v in request.form.items():
if k not in ALLOWED_FIELDS:
return f'{k} not allowed in product POST request', 403
product[k] = v
key = PRODUCTS_KEY.format(product['SKU'])
R.jsonset(key, Path.rootPath(), product)
return Response(status=200)
@app.route('/product/<string:sku>')
def get_product(sku):
'''
Get the JSON object for a product from its SKU
'''
key = PRODUCTS_KEY.format(sku)
return jsonify(R.jsonget(key))
@app.route('/product/<string:sku>', methods=['DELETE'])
def delete_product(sku):
'''
Remove a product from the catalog
'''
key = PRODUCTS_KEY.format(sku)
key_deleted = R.delete(key)
if key_deleted:
return Response(status=200)
else:
return Response(status=404)
########
## DB ##
########
PRODUCTS_KEY = 'prod:{}'
PRODUCTS_INDEX_KEY = 'idx:prod'
AUTOCOMPLETE_KEY = 'ac:prods&cats'
SAMPLE_DATA = [{'SKU': '43214563',
'categories': 'electronics,headphones',
'long_description': 'Monocle ipsum dolor sit amet k-pop smart bespoke, alluring wardrobe espresso Zürich charming Nordic ANA destination elegant bureaux handsome Melbourne. ',
'name': 'Craniumcandy - Wireless Over-the-Ear Headphones - Crazy Tropical',
'price': 100,
'rating': 1,
'short_description': 'Toto, K-pop sharp the highest quality sleepy boutique joy.'},
{'SKU': '431553432',
'name': 'Doodle - Sprite Buds True Wireless In-Ear Headphones - Night Black',
'short_description': 'Beams the highest quality remarkable Swiss concierge. Cosy signature the best extraordinary.',
'long_description': 'Discerning airport first-class, elegant conversation artisanal Beams flat white ryokan Helsinki Boeing 787 K-pop concierge soft power iconic. Toto Melbourne pintxos, joy destination global craftsmanship St Moritz smart premium boutique. Boeing 787 premium first-class extraordinary the best Zürich discerning elegant. Charming impeccable emerging sophisticated international Airbus A380 efficient Beams cosy Marylebone Muji Asia-Pacific. Charming uniforms Beams airport, essential Zürich global Nordic extraordinary Boeing 787 iconic vibrant.',
'price': 180,
'rating': 5,
'categories': 'electronics,headphones'},
{'SKU': '8743153432',
'name': 'Doodle - Sprite 6 Expert',
'short_description': 'Beams the highest quality remarkable Swiss concierge. Cosy signature the best extraordinary.',
'long_description': 'Discerning airport first-class, elegant conversation artisanal Beams flat white ryokan Helsinki Boeing 787 K-pop concierge soft power iconic. Toto Melbourne pintxos, joy destination global craftsmanship St Moritz smart premium boutique. Boeing 787 premium first-class extraordinary the best Zürich discerning elegant. Charming impeccable emerging sophisticated international Airbus A380 efficient Beams cosy Marylebone Muji Asia-Pacific. Charming uniforms Beams airport, essential Zürich global Nordic extraordinary Boeing 787 iconic vibrant.',
'price': 899,
'rating': 5,
'categories': 'cell phone,electronics'},
{'SKU': '4316647899',
'name': 'Blues Banjo Songs for Noobs - Gal Nimoy',
'short_description': 'The best Boeing 787 Lufthansa Toto. Destination Singapore efficient Nordic craftsmanship.',
'long_description': 'Wardrobe Fast Lane exclusive perfect delightful extraordinary Melbourne K-pop classic Airbus A380 elegant the highest quality. Emerging boutique concierge quality of life finest, punctual elegant delightful pintxos airport tote bag Muji flat white Swiss.',
'price': 23,
'rating': 3,
'categories': 'books,music'},
{'SKU': '84836424542',
'name': 'Be Here Now - Richard Alpert',
'short_description': 'The best Boeing 787 Lufthansa Toto. Destination Singapore efficient Nordic craftsmanship.',
'long_description': 'Wardrobe Fast Lane exclusive perfect delightful extraordinary Melbourne K-pop classic Airbus A380 elegant the highest quality. Emerging boutique concierge quality of life finest, punctual elegant delightful pintxos airport tote bag Muji flat white Swiss.',
'price': 42,
'rating': 3,
'categories': 'books'}]
def get_suggestions(stem):
'''
Get a list of auto complete suggestions from a given stem
'''
ac = AutoCompleter(AUTOCOMPLETE_KEY, conn=R)
return [str(sugg) for sugg in ac.get_suggestions(stem)]
def search_products(name=None, categories=None, price=None, rating=None, fields=None,
paging=None, sort_by='name', ascending=True, **kwargs):
'''
Search the product catalog
name - name of the product
categories - comma delimited category list (category1,category2)
price - price range, hyphen delimited (price_low-price_high)
rating - rating range, hyphen delmited (rating_low-rating_high)
fields - object fields to return in response
paging - comma delimited, show item range in this page (paging_start,paging_end)
sort_by - field to sort by
ascending - ascending or descending
'''
## for non-numeric search terms, we'll build the term query
search_terms = []
if name:
search_terms.append(f'@name:{name}')
if categories:
_categories = categories.split(',')
search_terms.append(f'@categories:{{{"|".join(_categories)}}}') ## tag search uses {} vs () for compounds
if search_terms:
## join the non-numeric terms together to create the query object
terms_query = ' '.join(search_terms)
else:
## no non-numeric ones were set, wildcard
terms_query = '*'
## create and configure query object
query = Query(terms_query)
query.sort_by(sort_by, asc=ascending)
if paging is not None:
query.paging(*paging.split(','))
if fields is not None:
query.return_fields(*fields.split(','))
## numeric terms and other query parameters
if price:
price_low, price_high = price.split('-')
query.add_filter(NumericFilter('price', price_low, price_high))
if rating:
rating_low, rating_high = rating.split('-')
query.add_filter(NumericFilter('rating', rating_low, rating_high))
## execute search
result = product_index().search(query)
return [loads(doc.json) for doc in result.docs] # in order to get these all into one json obj, we'll need to transerialze them
def product_index():
'''
Get or Create the product search index
'''
## check to see if the client exists, otherwise create it
client = SearchClient(PRODUCTS_INDEX_KEY, conn=R)
try:
client.info()
return client
except ResponseError:
print("index doesn't exist, creating")
## Index Defn Base
definition = IndexDefinition(prefix=[PRODUCTS_KEY.format('')], index_type=IndexType.JSON)
## Categories implemented as Tags - allows for more complex searching
ctg_field = TagField('$.categories', as_name='categories')
ctg_field_params = list(ctg_field.args)
ctg_field.args = tuple(ctg_field_params)
## actually create the index
client.create_index((
TagField("$.SKU", as_name='SKU'),
TextField("$.name", as_name='name'),
TextField("$.short_description", as_name='short_description'),
TextField("$.long_description", as_name='long_description'),
NumericField("$.price", sortable=True, as_name='price'),
NumericField("$.rating", sortable=True, as_name='rating'),
ctg_field),
definition=definition)
return client
def add_products():
'''
Ingests the sample data into the DB
'''
with R.pipeline(transaction=False) as pipeline:
for product in SAMPLE_DATA:
key = PRODUCTS_KEY.format(product['SKU'])
pipeline.jsonset(key, Path.rootPath(), product)
ac = AutoCompleter(AUTOCOMPLETE_KEY, conn=pipeline)
categories = product['categories'].split(',')
suggestions = [Suggestion(product['name'])]
suggestions.extend([Suggestion(category) for category in categories])
ac.add_suggestions(*suggestions)
pipeline.execute()
if __name__ == '__main__':
product_index()
add_products()
app.run(debug=True)