package redis_cli
import (
"context"
"github.com/nitishm/go-rejson/v4"
"github.com/redis/go-redis/v9"
)
type RedisStackClient struct {
Client *redis.Client
ctx context.Context
Rh *rejson.Handler
}
func NewRedisStackClient(addr string, password string, db int) *RedisStackClient {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
Protocol: 2,
//示例中的连接选项在 Protocol 字段中指定了 RESP2。我们建议您在 go-redis_cli 中使用 RESP2 进行 Redis 查询引擎操作,因为默认 RESP3 的某些响应结构目前不完整,因此您必须在自己的代码中处理“原始”响应。
//如果您确实想使用 RESP3,连接时应设置 UnstableResp3 选项,您还必须使用 RawResult() 和 RawVal() 方法而不是通常的 Result() 和 Val() 来访问命令结果
UnstableResp3: false,
})
ctx := context.Background()
rh := rejson.NewReJSONHandler()
rh.SetGoRedisClientWithContext(ctx, rdb)
return &RedisStackClient{
Client: rdb,
ctx: ctx,
Rh: rh,
}
}
// 基础命令封装
func (r *RedisStackClient) Set(ctx context.Context, key string, value interface{}) error {
return r.Client.Set(ctx, key, value, 0).Err()
}
func (r *RedisStackClient) Get(ctx context.Context, key string) (string, error) {
return r.Client.Get(ctx, key).Result()
}
创建index, 并新增测试数据:
// 示例结构体
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
City string `json:"city"`
}
var KeyPrefix = "user:"
func TestNewRedisStackClient(t *testing.T) {
cli := NewRedisStackClient("localhost:6379", "", 0)
//创建索引
ctx := context.Background()
_, err := cli.Client.FTCreate(
ctx,
"idx:users",
// Options:
&redis.FTCreateOptions{
OnJSON: true,
Prefix: []interface{}{KeyPrefix},
},
// Index schema fields:
&redis.FieldSchema{
FieldName: "$.id",
As: "id",
FieldType: redis.SearchFieldTypeText,
},
&redis.FieldSchema{
FieldName: "$.name",
As: "name",
FieldType: redis.SearchFieldTypeText,
},
&redis.FieldSchema{
FieldName: "$.city",
As: "city",
FieldType: redis.SearchFieldTypeText,
},
&redis.FieldSchema{
FieldName: "$.age",
As: "age",
FieldType: redis.SearchFieldTypeNumeric,
},
).Result()
if err != nil {
panic(err)
}
// 添加数据
var testUsers []*User = []*User{&User{ID: "1", Name: "libaobao", Age: 28, City: "珠海"}, &User{ID: "2", Name: "wangyiting", Age: 34, City: "北京"}, &User{ID: "3", Name: "zhangsan", Age: 38, City: "北京"}, &User{ID: "4", Name: "lisi", Age: 48, City: "合肥"}}
for _, v := range testUsers {
key := KeyPrefix + v.ID
_, err := cli.Rh.JSONSet(key, ".", v)
if err != nil {
panic(err)
}
}
}
通过FTSearch检索
func TestRedisStackClient_Get(t *testing.T) {
cli := NewRedisStackClient("localhost:6379", "", 0)
ctx := context.Background()
findAgeResult, err := cli.Client.FTSearch(
ctx,
"idx:users",
"libaobao @age:[20 40]",
//"@age:[20 40]",
).Result()
if err != nil {
panic(err)
}
fmt.Println(findAgeResult.Total)
for _, v := range findAgeResult.Docs {
fmt.Printf("📥 JSON.GET -> %+v\n", v)
fmt.Printf("📥 JSON.GET -> %+v\n", v.Fields) // map[$:{"id":"1","name":"lib aobao","age":28,"city":"珠海"}],RediSearch 2.x 以后 默认的 JSON 模式(RETURN 1 $)下的原始格式。
//它把整条 JSON 文档以字符串形式放到一个 map[string]string 里,键名固定为 $,值是JSON 字符串。
u := &User{}
_ = json.Unmarshal([]byte(v.Fields["$"]), u)
fmt.Printf("%+v\n", u) // {ID:1 Name:lib aobao Age:28 City:珠海}
}
}
测试用例返回结果:
image.png
如上图FTSearch检索的数据内容会存在Docs数组的Fields字段内, 它把整条 JSON 文档以字符串形式放到一个 map[string]string 里,键名固定为 “$”。解析成结构体时, 需要将其对应的value值转为[]byte, 并通过json 反序列化。
u := &User{}
_ = json.Unmarshal([]byte(v.Fields["$"]), u)
FTSearch 返回结果存在多条记录时, 只有第一个结果有值,其余为 nil
FT.SEARCH 在返回 JSON 文档时,默认把整条 JSON 放在字段名$ 里。
go-redis 的 FTSearch 解析器遇到 重名字段 时,只能拿到 第一条 记录中的 $,后面的记录因为字段名冲突被覆盖,于是你看到“只有第一个结果有值,其余为 nil”。
image.png
在 go-redis v9 里建索引时,不写 RETURN 就是最简单、最安全的方式:
RediSearch 会默认把 整条 JSON 作为字段 $ 返回,每条记录互不覆盖,go-redis 解析起来就不会出现「只有第一条有值」的问题。
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
// 1) 先删旧索引(如果存在)
_ = rdb.Do(ctx, "FT.DROPINDEX", "idx:user").Err()
// 2) 新建索引——**不写 RETURN**
err := rdb.Do(ctx,
"FT.CREATE", "idx:user",
"ON", "JSON",
"PREFIX", "1", "user:",
"SCHEMA",
"$.id", "AS", "id", "NUMERIC",
"$.name", "AS", "name", "TEXT",
"$.age", "AS", "age", "NUMERIC",
"$.city", "AS", "city", "TAG",
).Err()
if err != nil {
log.Fatal(err)
}
修改后输出结果:
image.png
- 数字字段匹配查询, 查询年龄在12 到 34岁之间的用户
-- FT.SEARCH index "@field:[value value]" SORTBY field LIMIT page_start page_end
或者
-- FT.SEARCH index "*" FILTER field start end
示例:
FT.SEARCH idx:user "@age:[12 34]"
FT.SEARCH idx:user "*" FilTER age 12 34
- 标签查询
--FT.SEARCH index "@field:{tag}" //标签查询必须使用花括号。
Ft.SEARCH doc:vactor_test "@genre:{persons}"
- 全文字段
--FT.SEARCH index "@field:\"phrase\""
Ft.SEARCH doc:vactor_test "@content:\"dog\""
//搜索匹配给定前缀的
-- FT.SEARCH index "@field: prefix*"
image.png
- 地理空间
--FT.SEARCH index "@geo_field:[lon lat radius unit]"
FT.SEARCH idx:bicycle "@store_location:[-0.1778 51.5524 20 mi]"
通过将中心坐标(经度、纬度)、半径和距离单位传递给FT.SEARCH命令,您可以构建半径查询。允许的单位有 m、km、mi 和 ft。
res1, err := rdb.FTSearchWithArgs(ctx,
"idx:bicycle", "@store_location:[$lon $lat $radius $units]",
&redis.FTSearchOptions{
Params: map[string]interface{}{
"lon": -0.1778,
"lat": 51.5524,
"radius": 20,
"units": "mi",
},
DialectVersion: 2,
},
).Result()
if err != nil {
panic(err)
}
fmt.Println(res1.Total) // >>> 1
for _, doc := range res1.Docs {
fmt.Println(doc.ID)
}
// >>> bicycle:5
- 组合查询
//查找包含单词 'kids' 或 'small' 的二手自行车
FT.SEARCH idx:bicycle "(kids | small) @condition:{used}"
//查找处于新状态且价格范围在 500 美元到 1000 美元之间的自行车
FT.SEARCH idx:bicycle "@price:[500 1000] @condition:{new}"
//查询表达式前的减号(-)表示否定该表达式。即 NOT 查询,从之前的价格范围内排除新自行车
FT.SEARCH idx:bicycle "@price:[500 1000] -@condition:{new}"
- 聚合查询-应用简单的映射函数 :
FT.AGGREGATE idx:bicycle "@condition:{new}" LOAD 2 "__key" "price" APPLY "@price - (@price * 0.1)" AS "discounted"
如果字段值尚未加载到聚合管道中,您可以通过 LOAD 子句强制加载它们。此子句接受字段数量 (n),后跟字段名称 ("field_1" .. "field_n")。字段 __key 是一个内置字段。
res1, err := rdb.FTAggregateWithArgs(ctx,
"idx:bicycle",
"@condition:{new}",
&redis.FTAggregateOptions{
Apply: []redis.FTAggregateApply{
{
Field: "@price - (@price * 0.1)",
As: "discounted",
},
},
Load: []redis.FTAggregateLoad{
{Field: "__key"},
{Field: "price"},
},
},
).Result()
if err != nil {
panic(err)
}
fmt.Println(len(res1.Rows)) // >>> 5
sort.Slice(res1.Rows, func(i, j int) bool {
return res1.Rows[i].Fields["__key"].(string) <
res1.Rows[j].Fields["__key"].(string)
})
for _, row := range res1.Rows {
fmt.Printf(
"__key=%v, discounted=%v, price=%v\n",
row.Fields["__key"],
row.Fields["discounted"],
row.Fields["price"],
)
}
// >>> __key=bicycle:0, discounted=243, price=270
// >>> __key=bicycle:5, discounted=729, price=810
// >>> __key=bicycle:6, discounted=2070, price=2300
// >>> __key=bicycle:7, discounted=387, price=430
// >>> __key=bicycle:8, discounted=1080, price=1200
- 聚合查询-分组与聚合 :
--FT.AGGREGATE index "query_expr" ... GROUPBY n "field_1" .. "field_n" REDUCE AGG_FUNC m "@field_param_1" .. "@field_param_m" AS "aggregated_result_field"
FT.AGGREGATE idx:bicycle "*" LOAD 1 price APPLY "@price<1000" AS price_category GROUPBY 1 @condition REDUCE SUM 1 "@price_category" AS "num_affordable"
-
分组:您可以按一个或多个字段进行分组。每个有序的字段值序列定义一个组。也可以按先前
APPLY ... AS
产生的值进行分组。 -
聚合:您必须将
AGG_FUNC
替换为支持的聚合函数之一(例如,SUM
或COUNT
)。完整的函数列表可在聚合参考文档中找到。将aggregated_result_field
替换为您选择的值。
以下查询展示了如何按字段 condition 进行分组,并根据先前派生的 price_category 应用归约。表达式 @price<1000 使得价格低于 1000 美元的自行车具有价格类别 1。否则,其价格类别为 0。输出是按价格类别分组的经济实惠的自行车数量
res2, err := rdb.FTAggregateWithArgs(ctx,
"idx:bicycle", "*",
&redis.FTAggregateOptions{
Load: []redis.FTAggregateLoad{
{Field: "price"},
},
Apply: []redis.FTAggregateApply{
{
Field: "@price<1000",
As: "price_category",
},
},
GroupBy: []redis.FTAggregateGroupBy{
{
Fields: []interface{}{"@condition"},
Reduce: []redis.FTAggregateReducer{
{
Reducer: redis.SearchSum,
Args: []interface{}{"@price_category"},
As: "num_affordable",
},
},
},
},
},
).Result()
if err != nil {
panic(err)
}
fmt.Println(len(res2.Rows)) // >>> 3
sort.Slice(res2.Rows, func(i, j int) bool {
return res2.Rows[i].Fields["condition"].(string) <
res2.Rows[j].Fields["condition"].(string)
})
for _, row := range res2.Rows {
fmt.Printf(
"condition=%v, num_affordable=%v\n",
row.Fields["condition"],
row.Fields["num_affordable"],
)
}
// >>> condition=new, num_affordable=3
// >>> condition=refurbished, num_affordable=1
// >>> condition=used, num_affordable=1
- 只分组不聚合, 使用TOLIST 函数按condition 字段将对应类别的数据检索处理
FT.AGGREGATE idx:bicycle "*" LOAD 1 "__key" GROUPBY 1 "@condition" REDUCE TOLIST 1 "__key" AS bicycles
查询输出:
image.png