goredigo

Testing with Golang, redis and time


I was trying to test a bit with Redis for the first time and I bumped into some confusion with HGET/HSET/HGETALL. My main problem was that I needed to store time, and I wanted to use a hash as I'll continuously update the time.

At first I read about how a MarshalBinary function such as this would save me:

func (f Foo) MarshalBinary() ([]byte, error) {
    return json.Marshal(f)
}

What that did was that it saved the struct as a json string, but only as a string and not as an actual Redis hash. What I ended up doing in the end was a fairly large boilerplate code that makes my struct I want to save into a map, and that one is properly stored as a hash in Redis.

type Foo struct {
    Number int       `json:"number"`
    ATime  time.Time `json:"atime"`
    String string    `json:"astring"`
}

func (f Foo) toRedis() map[string]interface{} {
    res := make(map[string]interface{})
    rt := reflect.TypeOf(f)
    rv := reflect.ValueOf(f)
    if rt.Kind() == reflect.Ptr {
        rt = rt.Elem()
        rv = rv.Elem()
    }
    for i := 0; i < rt.NumField(); i++ {
        f := rt.Field(i)
        v := rv.Field(i)
        switch t := v.Interface().(type) {
        case time.Time:
            res[f.Tag.Get("json")] = t.Format(time.RFC3339)
        default:
            res[f.Tag.Get("json")] = t
        }
    }
    return res
}

Then to parse back into my Foo struct when calling HGetAll(..).Result(), I'm getting the result as a map[string]string and create a new Foo with these functions:

func setRequestParam(arg *Foo, i int, value interface{}) {
    v := reflect.ValueOf(arg).Elem()
    f := v.Field(i)
    if f.IsValid() {
        if f.CanSet() {
            if f.Kind() == reflect.String {
                f.SetString(value.(string))
                return
            } else if f.Kind() == reflect.Int {
                f.Set(reflect.ValueOf(value))
                return
            } else if f.Kind() == reflect.Struct {
                f.Set(reflect.ValueOf(value))
            }
        }
    }
}

func fromRedis(data map[string]string) (f Foo) {
    rt := reflect.TypeOf(f)
    rv := reflect.ValueOf(f)

    for i := 0; i < rt.NumField(); i++ {
        field := rt.Field(i)
        v := rv.Field(i)
        switch v.Interface().(type) {
        case time.Time:
            if val, ok := data[field.Tag.Get("json")]; ok {
                if ti, err := time.Parse(time.RFC3339, val); err == nil {
                    setRequestParam(&f, i, ti)
                }
            }
        case int:
            if val, ok := data[field.Tag.Get("json")]; ok {
                in, _ := strconv.ParseInt(val, 10, 32)
                setRequestParam(&f, i, int(in))

            }
        default:
            if val, ok := data[field.Tag.Get("json")]; ok {
                setRequestParam(&f, i, val)
            }
        }
    }
    return
}

The whole code in its ungloryness is here

I'm thinking that there must be a saner way to solve this problem? Or am I forced to do something like this? The struct I need to store only contains ints, strings and time.Times.

*edit The comment field is a bit short so doing an edit instead:

I did originally solve it like 'The Fool' suggested in comments and as an answer. The reason I changed to the above part, while more complex a solution, I think it's more robust for changes. If I go with a hard coded map solution, I'd "have to" have:

My intended question/hope though was: Do I really have to jump through hoops like this to store time in Redis hashes with go? Fair, time.Time isn't a primitive and Redis isn't a (no)sql database, but I would consider timestamps in cache a very common use case (in my case a heartbeat to keep track of timed out sessions together with metadata enough to permanently store it, thus the need to update them). But maybe I'm misusing Redis, and I should rather have two entries, one for the data and one for the timestamp, which would then leave me with two simple get/set functions taking in time.Time and returning time.Time.


Solution

  • You can use redigo/redis#Args.AddFlat to convert struct to redis hash we can map the value using redis tag.

    package main
    
    import (
      "fmt"
    
      "time"
      "github.com/gomodule/redigo/redis"
    )
    
    type Foo struct {
        Number  int64     `json:"number"  redis:"number"`
        ATime   time.Time `json:"atime"   redis:"atime"`
        AString string    `json:"astring" redis:"astring"`
    }
    
    func main() {
      c, err := redis.Dial("tcp", ":6379")
      if err != nil {
        fmt.Println(err)
        return
      }
      defer c.Close()
    
      t1 := time.Now().UTC()
      var foo Foo
      foo.Number = 10000000000
      foo.ATime = t1
      foo.AString = "Hello"
    
      tmp := redis.Args{}.Add("id1").AddFlat(&foo)
      if _, err := c.Do("HMSET", tmp...); err != nil {
        fmt.Println(err)
        return
      }
    
      v, err := redis.StringMap(c.Do("HGETALL", "id1"))
      if err != nil {
        fmt.Println(err)
        return
      }
      fmt.Printf("%#v\n", v)
    }
    

    Then to update ATime you can use redis HSET

    if _, err := c.Do("HMSET", "id1", "atime", t1.Add(-time.Hour * (60 * 60 * 24))); err != nil {
      fmt.Println(err)
      return
    }
    

    And to retrieve it back to struct we have to do some reflect magic

    func structFromMap(src map[string]string, dst interface{}) error {
      dt := reflect.TypeOf(dst).Elem()
      dv := reflect.ValueOf(dst).Elem()
    
      for i := 0; i < dt.NumField(); i++ {
        sf := dt.Field(i)
        sv := dv.Field(i)
        if v, ok := src[strings.ToLower(sf.Name)]; ok {
          switch sv.Interface().(type) {
            case time.Time:
              format := "2006-01-02 15:04:05 -0700 MST"
              ti, err := time.Parse(format, v)
              if err != nil {
                return err
              }
              sv.Set(reflect.ValueOf(ti))
            case int, int64:
              x, err := strconv.ParseInt(v, 10, sv.Type().Bits())
              if err != nil {
                return err
              }
              sv.SetInt(x)
            default:
              sv.SetString(v)
          }
        }
      }
    
      return nil
    }
    

    Final Code

    package main
    
    import (
      "fmt"
    
      "time"
      "reflect"
      "strings"
      "strconv"
    
      "github.com/gomodule/redigo/redis"
    )
    
    type Foo struct {
        Number  int64     `json:"number"  redis:"number"`
        ATime   time.Time `json:"atime"   redis:"atime"`
        AString string    `json:"astring" redis:"astring"`
    }
    
    func main() {
      c, err := redis.Dial("tcp", ":6379")
      if err != nil {
        fmt.Println(err)
        return
      }
      defer c.Close()
    
      t1 := time.Now().UTC()
      var foo Foo
      foo.Number = 10000000000
      foo.ATime = t1
      foo.AString = "Hello"
    
      tmp := redis.Args{}.Add("id1").AddFlat(&foo)
      if _, err := c.Do("HMSET", tmp...); err != nil {
        fmt.Println(err)
        return
      }
    
      v, err := redis.StringMap(c.Do("HGETALL", "id1"))
      if err != nil {
        fmt.Println(err)
        return
      }
      fmt.Printf("%#v\n", v)
    
      if _, err := c.Do("HMSET", "id1", "atime", t1.Add(-time.Hour * (60 * 60 * 24))); err != nil {
        fmt.Println(err)
        return
      }
    
      var foo2 Foo
      structFromMap(v, &foo2)
      fmt.Printf("%#v\n", foo2)
    }
    
    func structFromMap(src map[string]string, dst interface{}) error {
      dt := reflect.TypeOf(dst).Elem()
      dv := reflect.ValueOf(dst).Elem()
    
      for i := 0; i < dt.NumField(); i++ {
        sf := dt.Field(i)
        sv := dv.Field(i)
        if v, ok := src[strings.ToLower(sf.Name)]; ok {
          switch sv.Interface().(type) {
            case time.Time:
              format := "2006-01-02 15:04:05 -0700 MST"
              ti, err := time.Parse(format, v)
              if err != nil {
                return err
              }
              sv.Set(reflect.ValueOf(ti))
            case int, int64:
              x, err := strconv.ParseInt(v, 10, sv.Type().Bits())
              if err != nil {
                return err
              }
              sv.SetInt(x)
            default:
              sv.SetString(v)
          }
        }
      }
    
      return nil
    }
    

    Note: The struct field name is matched with the redis tag