由 RediSearch 提供支持的 RedisJSON 现已推出公开预览版。在这篇博文中,我们将深入探讨如何开始使用 RedisJSON 新的 JSON 索引、查询和全文搜索功能,我们将通过了解它如何用于构建 RedisMart 的产品目录服务来实现。如果您错过了,RedisMart 是我们在 2021 年 RedisConf 主题演讲中演示的功能齐全的实时零售商店。我们还发表了一篇博客,深入探讨了 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 模糊搜索功能驱动的自动完成下拉菜单中
使用 Suggestions 功能可以轻松实现模糊搜索,我们可以将其添加到添加到目录中的任何数据
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()
现在我们已经设置好索引和 suggestions,接下来为我们的产品微服务构建一个搜索查询函数
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
http://localhost: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)