go-redis 使用RedisSearch踩坑

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"
  1. 分组:您可以按一个或多个字段进行分组。每个有序的字段值序列定义一个组。也可以按先前 APPLY ... AS 产生的值进行分组。
  2. 聚合:您必须将 AGG_FUNC 替换为支持的聚合函数之一(例如,SUMCOUNT)。完整的函数列表可在聚合参考文档中找到。将 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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容