使用 ReJSON 在 Redis 中保存 Go 结构体
image
图像授权 https://Redislabs.com/blog/Redis-go-designed-improve-performance/
大部分人可能对 Redis 都很熟悉了。对于外行人来说,Redis 是最广为人知并广泛应用的数据库/缓存产品,起码也是之一。
官方文档是这么描述 Redis 的:
Redis 是一个开源(BSD 许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。 它支持的数据结构有字符串(strings),散列(hashes),列表(lists),集合(sets),有序集合(sorted sets)与范围查询, bitmaps, hyperloglogs 和 地理空间(geospatial)索引半径查询。 Redis 内置了复制(replication),LUA脚本(Lua scripting), LRU 驱动事件(LRU eviction),事务(transactions) 和不同级别的磁盘持久化(persistence), 并通过 Redis 哨兵(Sentinel)和自动分区(Cluster)提供高可用性(high availability)。
将 Redis 与其它(传统)数据库区分开来的是,Redis 是一个键-值 存储(并且是在内存中)。这意味着在这个数据库中所有的值都与一个 key 相关联(想想字典的情况)。不过我跑题了,这篇文章可不是讲 Redis 的,让我们言归正传。
使用 Go 语言与 Redis 进行交互
当 Go 开发者使用 Redis 时,有时会需要将我们的对象缓存到 Redis 中。我们看看如何通过 Redis 中的 HMSET 来实现这点。
一个简单的 go 结构体可能会像这样,
1type SimpleObject struct {
2 FieldA string
3 FieldB int
4}
5simpleObject := SimpleObject{"John Doe", 24}
很明显,为了将对象存到 Redis 中,我们必须将它转化成一个键值对,我们将结构体的字段名作为 key,字段的值作为这个 key 对应的值。
对所有的字段取一个哈希值也是非常好的键值,将对象所有的字段与这个对象自身绑定起来。在 Redis-cli 中我们可以这么做:
1127.0.0.1:6379> HMSET simple_object fieldA "John Doe" fieldB 24
2OK
用 HGETALL 命令获取的结果是,
1127.0.0.1:6379> HGETALL simple_object
2fieldA
3John Doe
4fieldB
524
好吧,现在我们知道对象是怎样序列化后存入数据库中的,让我们继续用程序的方式完成这个工作!
虽然 Redis 的 Go 客户端很多,但我使用 redigo,它在 github 上有一个很不错的社区,而且也是最常用的 Redis 的 Go 客户端之一,有超过 4K 个星星。
Redigo 助手函数 — AddFlat 和 ScanStruct
Redigo自带了一系列很棒的助手函数,其中我们将用到 AddFlat ,在我们将结构体存入 Redis 之前,用它将结构体扁平化。
1// 获得链接对象
2conn, err := Redis.Dial("tcp", "localhost:6379")
3if err != nil {
4 return
5}
6// 使用 Do 方法调用命令
7_, err = conn.Do("HMSET", Redis.Args{"simple_object"}.AddFlat(simpleObject)...)
8if err != nil {
9 return
10}
现在,如果你希望读回这个对象,我们可以使用 HGETALL 命令,
1value, err := Redis.Values(conn.Do("HGETALL", key))
2if err != nil {
3 return
4}
5object := SimpleStruct{}
6err = Redis.ScanStruct(value, &object)
7if err != nil {
8 return
9}
很简单,对吧?让我们在看看更深入的一些问题…
Go 结构体中嵌套的对象
现在,我们来看一个更复杂的结构体,
1type Student struct {
2 Info *StudentDetails `json:"info,omitempty"`
3 Rank int `json:"rank,omitempty"`
4}
5type StudentDetails struct {
6 FirstName string
7 LastName string
8 Major string
9}
10studentJD := Student{
11 Info: &StudentDetails{
12 FirstName: "John",
13 LastName: "Doe",
14 Major: "CSE",
15 },
16 Rank: 1,
17}
现在我们有一个嵌套的结构体,StudentDetails
是 Student
对象的一个成员。
让我们再用 HMSET
试试看,
1// 用 Do 方法调用命令
2_, err = conn.Do("HMSET", Redis.Args{"JohnDoe"}.AddFlat(studentJD)...)
3if err != nil {
4 return
5}
如果我们再看看 Redis 中存进了什么,我们可以看到的是这样的,
1127.0.0.1:6379> HGETALL JohnDoe
2Info
3&{John Doe CSE}
4Rank
51
这就是问题点了。当我们想从 Redis 中读数据并转化为对象时,ScanStruct 会报错,
1redigo.ScanStruct: cannot assign field Info: cannot convert from Redis bulk string to *main.StudentDetails
EPIC FAIL !
这是因为 Redis 将所有的东西都存为字符串[大对象使用 bulk 字符串]。
现在该怎么办?
快速的搜索可以给你一些解决方案,其中之一是建议使用一个封装处理器(Marshaler)(JSON
marshal),其他的的方案建议用 MessagePack
。
以下我将展示采用 JSON
的解决方案
1b, err := json.Marshal(&studentJD)
2if err != nil {
3 return
4}
5_, err = conn.Do("SET", "JohnDoe", string(b))
6if err != nil {
7 return
8}
需要取回值时,只需要使用 GET
命令将 JSON
字符串读取回来就可以了。
1objStr, err = Redis.String(conn.Do("GET", "JohnDoe"))
2if err != nil {
3 return
4}
5b := []byte(objStr)
6student := &Student{}
7err = json.Unmarshal(b, student)
8if err != nil {
9 return
10}
如果我们是希望将对象完整的缓存下来,这个方案工作的很好。但是,如果我们希望在对象上进行增加、修改或者读取一个字段,比如,John Doe 将他的专业从 CSE 改成了 EE,该怎么办?
唯一的办法是,先读出 JSON 字符串,转化为对象,修改对象,然后重新将对象存回 Redis。这看起来可是不少的工作!
如果你发现,使用 Hash,通过
HGET
/HSET
命令来实现这一点很简单。如果只是这样,那就这么做吧。(原文:If you are wondering, doing this with the Hash is trivial by using the HGET/HSET commands. If only, that worked — bummer!)
ReJSON
优秀的 RedisLabs 团队给我们带来了一个解决方案,让我们可以对应像操作传统 JSON 对象那样操作 Redis 中的对象。
让我们马上来看看。我从 rejson 的文档中挑选了这个例子,
1127.0.0.1:6379> JSON.SET amoreinterestingexample . '[ true, { "answer": 42 }, null ]'
2OK
3127.0.0.1:6379> JSON.GET amoreinterestingexample
4"[true,{\"answer\":42},null]"
5127.0.0.1:6379> JSON.GET amoreinterestingexample [1].answer
6“42”
7127.0.0.1:6379> JSON.DEL amoreinterestingexample [-1]
81
9127.0.0.1:6379> JSON.GET amoreinterestingexample
10"[true,{\"answer\":42}]"
用程序来实现这个,我们绝对可以使用 Redigo
的原生形态[这意味者我们可以使用 conn.Do(...)
命令调用任何 Redis 支持的命令]。
然而,我花了一些时间将所有的 ReJSON
的命令转换成了 Go 包,叫做 go-rejson。回到我们之前的 Student
对象,我们可以用以下步骤使用程序将它存入 Redis 中,
1import "github.com/nitishm/go-rejson"
2_, err = rejson.JSONSet(conn, "JohnDoeJSON", ".", studentJD, false, false)
3if err != nil {
4 return
5}
在 redis-cli
中我们可以查到,
1127.0.0.1:6379> JSON.GET JohnDoeJSON
2{"info":{"FirstName":"John","LastName":"Doe","Major":"CSE"},"rank":1}
如果我只想从 Redis 条目(entry)读取 info
字段,我会执行 JSON.GET
,如下所示,
1127.0.0.1:6379> JSON.GET JohnDoeJSON .info
2{"FirstName":"John","LastName":”Doe","Major":"CSE"}3
类似地,对于 rank
字段,我通过 .rank
引用,
1127.0.0.1:6379> JSON.GET JohnDoeJSON .rank
21
使用程序来获取 student 对象,我们可以通过 JSONGet()
方法调用 JSON.GET
命令,
1v, err := rejson.JSONGet(conn, "JohnDoeJSON", "")
2if err != nil {
3 return
4}
5outStudent := &Student{}
6err = json.Unmarshal(outJSON.([]byte), outStudent)
7if err != nil {
8 return
9}
为了给 rank
字段赋值,我们可以在 .rank
字段上使用 JSONSet()
方法来调用 JSON.SET
命令,
1_, err = rejson.JSONSet(conn, "JohnDoeJSON", ".info.Major", "EE", false, false)
2if err != nil {
3 return
4}
在 redis-cli
中我们查看这个条目,可以看到,
1127.0.0.1:6379> JSON.GET JohnDoeJSON
2{"info":{"FirstName":"John","LastName":"Doe","Major":"EE"},"rank":1}
运行这个例子
用 Docker 启动带 rejson 模块的 Redis
1docker run -p 6379:6379 --name Redis-rejson Redislabs/rejson:latest
从 github 上克隆这个例子
1# git clone https://github.com/nitishm/rejson-struct.git
2# cd rejson-struct
3# go run main.go