From b62080c4bba312fe1744469a806c3bc2b3c8d391 Mon Sep 17 00:00:00 2001 From: Gerasimos Maropoulos Date: Sun, 22 Apr 2018 13:52:36 +0300 Subject: [PATCH] Sessions are now in full sync with the registered database, on `acquire(init), set, get, delete, clear, visit, len, release(destroy)` as requested by almost everyone. https://github.com/kataras/iris/issues/969 Former-commit-id: 49fcdb93106a78f0a24ad3fb4d8725e35e98451a --- .github/ISSUE_TEMPLATE.md | 5 +- .travis.yml | 4 + CONTRIBUTING.md | 17 +- README.md | 9 +- README_GR.md | 9 +- README_RU.md | 9 +- README_ZH.md | 9 +- _examples/README.md | 3 - _examples/README_ZH.md | 3 - _examples/sessions/README.md | 3 - _examples/sessions/database/badger/main.go | 6 +- _examples/sessions/database/boltdb/main.go | 90 -------- _examples/sessions/database/file/main.go | 88 -------- _examples/sessions/database/leveldb/main.go | 90 -------- _examples/sessions/database/redis/main.go | 30 ++- core/netutil/client.go | 5 +- sessions/database.go | 185 +++++---------- sessions/lifetime.go | 6 + sessions/provider.go | 101 +-------- sessions/session.go | 59 +---- sessions/sessiondb/badger/database.go | 238 ++++++++++++-------- sessions/sessiondb/boltdb/database.go | 230 ------------------- sessions/sessiondb/file/database.go | 203 ----------------- sessions/sessiondb/leveldb/database.go | 183 --------------- sessions/sessiondb/redis/database.go | 154 +++++++++---- sessions/sessiondb/redis/service/service.go | 64 +++++- sessions/sessions.go | 2 +- sessions/transcoding.go | 49 ++++ 28 files changed, 496 insertions(+), 1358 deletions(-) delete mode 100644 _examples/sessions/database/boltdb/main.go delete mode 100644 _examples/sessions/database/file/main.go delete mode 100644 _examples/sessions/database/leveldb/main.go delete mode 100644 sessions/sessiondb/boltdb/database.go delete mode 100644 sessions/sessiondb/file/database.go delete mode 100644 sessions/sessiondb/leveldb/database.go create mode 100644 sessions/transcoding.go diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 5fc7c444..503ff11f 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -5,4 +5,7 @@ Documentation for the Iris project can be found at . Love iris? Please consider supporting the project: -👉 https://iris-go.com/donate \ No newline at end of file +👉 https://iris-go.com/donate + +Care to be part of a larger community? Fill our user experience form: +👉 https://goo.gl/forms/lnRbVgA6ICTkPyk02 \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 427787be..6e6ada55 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,10 @@ go: - "go1.9" - "go1.10" go_import_path: github.com/kataras/iris +# we disable test caching via GOCACHE=off +env: + global: + - GOCACHE=off install: - go get ./... # for iris-contrib/httpexpect, kataras/golog script: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8c1cb38..ad7fa15d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,19 +85,4 @@ Thank you to all the people who have already contr Thank you to all our backers! [Become a backer](https://iris-go.com/donate) - - -### Sponsors - -Thank you to all our sponsors! (please ask your company to also support this open source project by [becoming a sponsor](https://opencollective.com/iris#sponsor)) - - - - - - - - - - - + diff --git a/README.md b/README.md index 8d7f7e53..2b4bf35e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Learn what [others say about Iris](#support) and [star](https://github.com/katar Thank you to all our backers! 🙏 [Become a backer](https://iris-go.com/donate) - + ```sh $ cat example.go @@ -216,13 +216,6 @@ Iris, unlike others, is 100% compatible with the standards and that's why the ma There are many companies and start-ups looking for Go web developers with Iris experience as requirement, we are searching for you every day and we post those information via our [facebook page](https://www.facebook.com/iris.framework), like the page to get notified, we have already posted some of them. -### Sponsors - -Thank you to all our sponsors! (please ask your company to also support this open source project by [becoming a sponsor](https://opencollective.com/iris#sponsor)) - - - - ## License Iris is licensed under the [3-Clause BSD License](LICENSE). Iris is 100% free and open-source software. diff --git a/README_GR.md b/README_GR.md index eae1e3bf..d4bb5759 100644 --- a/README_GR.md +++ b/README_GR.md @@ -16,7 +16,7 @@ Eυχαριστούμε όλους τους υποστηρικτές μας! 🙏 [Γίνετε ένας από αυτούς](https://iris-go.com/donate) - + ```sh $ cat example.go @@ -218,13 +218,6 @@ _Η τελευταία ενημέρωση έγινε την [Τρίτη, 21 Νο Υπάρχουν πολλές νεοσύστατες εταιρείες που αναζητούν Go web developers με εμπειρία Iris ως απαίτηση, ψάχνουμε καθημερινά και δημοσιεύουμε αυτές τις πληροφορίες μέσω της [σελίδας μας στο facebook](https://www.facebook.com/iris.framework), κάντε like για να λαμβάνετε ειδοποιήσεις, έχουμε ήδη δημοσιεύσει ορισμένες από αυτές(τις θέσεις εργασίας). -### Χορηγοί - -Ευχαριστούμε όλους τους χορηγούς μας! (παρακαλώ ρωτήστε την εταιρία σας να υποστηρίξει επίσης αυτό το έργο ανοιχτού κώδικα με το [να γίνει χορηγός](https://opencollective.com/iris#sponsor)) - - - - ## License Το Iris διαθέτει άδεια βάσει του [3-Clause BSD License](LICENSE). Το Iris είναι 100% δωρεάν και ανοιχτού κώδικα λογισμικό. diff --git a/README_RU.md b/README_RU.md index c7e7f79f..249556f1 100644 --- a/README_RU.md +++ b/README_RU.md @@ -16,7 +16,7 @@ Iris предоставляет красиво выразительную и у Спасибо всем, кто поддерживал нас! 🙏 [Поддержать нас](https://iris-go.com/donate) - + ```sh $ cat example.go @@ -218,13 +218,6 @@ Iris, в отличие от других, на 100% совместим со с Есть много компаний и стартапов, находящиеся в поисках Go веб-разработчиков с опытом работы с Iris как в качестве требования, которые мы подыскиваем для вас каждый день. Мы публикуем эту информацию на нашей [странице в Facebook](https://www.facebook.com/iris.framework). Ставьте Like, чтобы получите уведомления. Мы уже опубликовали некоторые из них. -### Спонсоры - -Спасибо всем нашим спонсорам! (пожалуйста, попросите вашу компанию также поддержать этот проект с открытым исходным кодом, [став спонсором](https://opencollective.com/iris#sponsor)) - - - - ## Лицензия Iris лицензируется в соответствии с [BSD 3-Clause лицензией](LICENSE). Iris - это бесплатное программное обеспечение с открытым исходным кодом на 100%. diff --git a/README_ZH.md b/README_ZH.md index 04b289b7..a20ad352 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -16,7 +16,7 @@ Iris 功能强大、使用简单,它将会是你下一个网站、API 服务 感谢所有的支持者! 🙏 [支持我们](https://iris-go.com/donate) - + ```sh $ cat example.go @@ -209,13 +209,6 @@ Iris 拥有大量的中间件 [[1]](middleware/)[[2]](https://github.com/iris-co 有很多公司都在寻找具有 Iris 经验的 Go 网站开发者,我们通过 [facebook page](https://www.facebook.com/iris.framework) 发布这些招聘信息。 -### 赞助 - -感谢所有赞助者! (希望贵公司赞助支持这个开源项目) - - - - ## 授权协议 Iris 授权基于 [3-Clause BSD License](LICENSE). Iris 是 100% 免费和开源软件。 diff --git a/_examples/README.md b/_examples/README.md index 082b851a..894efab9 100644 --- a/_examples/README.md +++ b/_examples/README.md @@ -400,10 +400,7 @@ iris session manager lives on its own [package](https://github.com/kataras/iris/ - [Secure Cookie](sessions/securecookie/main.go) - [Flash Messages](sessions/flash-messages/main.go) - [Databases](sessions/database) - * [File](sessions/database/file/main.go) - * [BoltDB](sessions/database/boltdb/main.go) * [Badger](sessions/database/badger/main.go) - * [LevelDB](sessions/database/leveldb/main.go) * [Redis](sessions/database/redis/main.go) > You're free to use your own favourite sessions package if you'd like so. diff --git a/_examples/README_ZH.md b/_examples/README_ZH.md index ea377833..02bee0c5 100644 --- a/_examples/README_ZH.md +++ b/_examples/README_ZH.md @@ -399,10 +399,7 @@ Iris session 管理独立包 [package](https://github.com/kataras/iris/tree/mast - [Secure Cookie](sessions/securecookie/main.go) - [Flash Messages](sessions/flash-messages/main.go) - [Databases](sessions/database) - * [File](sessions/database/file/main.go) - * [BoltDB](sessions/database/boltdb/main.go) * [Badger](sessions/database/badger/main.go) - * [LevelDB](sessions/database/leveldb/main.go) * [Redis](sessions/database/redis/main.go) > 可以随意使用自定义的 Session 管理包。 diff --git a/_examples/sessions/README.md b/_examples/sessions/README.md index a28d7ead..94be5c86 100644 --- a/_examples/sessions/README.md +++ b/_examples/sessions/README.md @@ -12,9 +12,6 @@ Some trivial examples, - [Flash Messages](https://github.com/kataras/iris/blob/master/_examples/sessions/flash-messages/main.go) - [Databases](https://github.com/kataras/iris/tree/master/_examples/sessions/database) * [BadgerDB](https://github.com/kataras/iris/blob/master/_examples/sessions/database/badger/main.go) **fastest** - * [File](https://github.com/kataras/iris/blob/master/_examples/sessions/database/file/main.go) - * [BoltDB](https://github.com/kataras/iris/blob/master/_examples/sessions/database/boltdb/main.go) - * [LevelDB](https://github.com/kataras/iris/blob/master/_examples/sessions/database/leveldb/main.go) * [Redis](https://github.com/kataras/iris/blob/master/_examples/sessions/database/redis/main.go) ## Overview diff --git a/_examples/sessions/database/badger/main.go b/_examples/sessions/database/badger/main.go index 0011f03c..20bbc809 100644 --- a/_examples/sessions/database/badger/main.go +++ b/_examples/sessions/database/badger/main.go @@ -20,9 +20,11 @@ func main() { db.Close() }) + defer db.Close() // close and unlock the database if application errored. + sess := sessions.New(sessions.Config{ Cookie: "sessionscookieid", - Expires: 45 * time.Minute, // <=0 means unlimited life + Expires: 45 * time.Second, // <=0 means unlimited life. Defaults to 0. }) // @@ -89,5 +91,5 @@ func main() { sess.ShiftExpiration(ctx) }) - app.Run(iris.Addr(":8080")) + app.Run(iris.Addr(":8080"), iris.WithoutServerError(iris.ErrServerClosed)) } diff --git a/_examples/sessions/database/boltdb/main.go b/_examples/sessions/database/boltdb/main.go deleted file mode 100644 index 48b11784..00000000 --- a/_examples/sessions/database/boltdb/main.go +++ /dev/null @@ -1,90 +0,0 @@ -package main - -import ( - "time" - - "github.com/kataras/iris" - - "github.com/kataras/iris/sessions" - "github.com/kataras/iris/sessions/sessiondb/boltdb" -) - -func main() { - db, _ := boltdb.New("./sessions/sessions.db", 0666, "users") - - // close and unlock the database when control+C/cmd+C pressed - iris.RegisterOnInterrupt(func() { - db.Close() - }) - - sess := sessions.New(sessions.Config{ - Cookie: "sessionscookieid", - Expires: 45 * time.Minute, // <=0 means unlimited life - }) - - // - // IMPORTANT: - // - sess.UseDatabase(db) - - // the rest of the code stays the same. - app := iris.New() - - app.Get("/", func(ctx iris.Context) { - ctx.Writef("You should navigate to the /set, /get, /delete, /clear,/destroy instead") - }) - app.Get("/set", func(ctx iris.Context) { - s := sess.Start(ctx) - //set session values - s.Set("name", "iris") - - //test if setted here - ctx.Writef("All ok session setted to: %s", s.GetString("name")) - }) - - app.Get("/set/{key}/{value}", func(ctx iris.Context) { - key, value := ctx.Params().Get("key"), ctx.Params().Get("value") - s := sess.Start(ctx) - // set session values - s.Set(key, value) - - // test if setted here - ctx.Writef("All ok session setted to: %s", s.GetString(key)) - }) - - app.Get("/get", func(ctx iris.Context) { - // get a specific key, as string, if no found returns just an empty string - name := sess.Start(ctx).GetString("name") - - ctx.Writef("The name on the /set was: %s", name) - }) - - app.Get("/get/{key}", func(ctx iris.Context) { - // get a specific key, as string, if no found returns just an empty string - name := sess.Start(ctx).GetString(ctx.Params().Get("key")) - - ctx.Writef("The name on the /set was: %s", name) - }) - - app.Get("/delete", func(ctx iris.Context) { - // delete a specific key - sess.Start(ctx).Delete("name") - }) - - app.Get("/clear", func(ctx iris.Context) { - // removes all entries - sess.Start(ctx).Clear() - }) - - app.Get("/destroy", func(ctx iris.Context) { - //destroy, removes the entire session data and cookie - sess.Destroy(ctx) - }) - - app.Get("/update", func(ctx iris.Context) { - // updates expire date with a new date - sess.ShiftExpiration(ctx) - }) - - app.Run(iris.Addr(":8080")) -} diff --git a/_examples/sessions/database/file/main.go b/_examples/sessions/database/file/main.go deleted file mode 100644 index 1ca116a6..00000000 --- a/_examples/sessions/database/file/main.go +++ /dev/null @@ -1,88 +0,0 @@ -package main - -import ( - "time" - - "github.com/kataras/iris" - - "github.com/gorilla/securecookie" - "github.com/kataras/iris/sessions" - "github.com/kataras/iris/sessions/sessiondb/file" -) - -func main() { - db, _ := file.New("./sessions/", 0755) - - sess := sessions.New(sessions.Config{ - Cookie: "sessionscookieid", - Expires: 24 * time.Hour, // <=0 means unlimited life - Encoding: securecookie.New([]byte("C2O6J6oYTd0CBCNERkWZK8jGOXTXf9X2"), - []byte("UTp6fJsicraGxA2cslELrrLX7msg5jfE")), - }) - - // - // IMPORTANT: - // - sess.UseDatabase(db) - - // the rest of the code stays the same. - app := iris.New() - - app.Get("/", func(ctx iris.Context) { - ctx.Writef("You should navigate to the /set, /get, /delete, /clear,/destroy instead") - }) - app.Get("/set", func(ctx iris.Context) { - s := sess.Start(ctx) - //set session values - s.Set("name", "iris") - - //test if setted here - ctx.Writef("All ok session setted to: %s", s.GetString("name")) - }) - - app.Get("/set/{key}/{value}", func(ctx iris.Context) { - key, value := ctx.Params().Get("key"), ctx.Params().Get("value") - s := sess.Start(ctx) - // set session values - s.Set(key, value) - - // test if setted here - ctx.Writef("All ok session setted to: %s", s.GetString(key)) - }) - - app.Get("/get", func(ctx iris.Context) { - // get a specific key, as string, if no found returns just an empty string - name := sess.Start(ctx).GetString("name") - - ctx.Writef("The name on the /set was: %s", name) - }) - - app.Get("/get/{key}", func(ctx iris.Context) { - // get a specific key, as string, if no found returns just an empty string - name := sess.Start(ctx).GetString(ctx.Params().Get("key")) - - ctx.Writef("The name on the /set was: %s", name) - }) - - app.Get("/delete", func(ctx iris.Context) { - // delete a specific key - sess.Start(ctx).Delete("name") - }) - - app.Get("/clear", func(ctx iris.Context) { - // removes all entries - sess.Start(ctx).Clear() - }) - - app.Get("/destroy", func(ctx iris.Context) { - //destroy, removes the entire session data and cookie - sess.Destroy(ctx) - }) - - app.Get("/update", func(ctx iris.Context) { - // updates expire date with a new date - sess.ShiftExpiration(ctx) - }) - - app.Run(iris.Addr(":8080"), iris.WithoutVersionChecker) -} diff --git a/_examples/sessions/database/leveldb/main.go b/_examples/sessions/database/leveldb/main.go deleted file mode 100644 index a7ac3c38..00000000 --- a/_examples/sessions/database/leveldb/main.go +++ /dev/null @@ -1,90 +0,0 @@ -package main - -import ( - "time" - - "github.com/kataras/iris" - - "github.com/kataras/iris/sessions" - "github.com/kataras/iris/sessions/sessiondb/leveldb" -) - -func main() { - db, _ := leveldb.New("./sessions/") - - // close and unlock the database when control+C/cmd+C pressed - iris.RegisterOnInterrupt(func() { - db.Close() - }) - - sess := sessions.New(sessions.Config{ - Cookie: "sessionscookieid", - Expires: 45 * time.Minute, // <=0 means unlimited life - }) - - // - // IMPORTANT: - // - sess.UseDatabase(db) - - // the rest of the code stays the same. - app := iris.New() - - app.Get("/", func(ctx iris.Context) { - ctx.Writef("You should navigate to the /set, /get, /delete, /clear,/destroy instead") - }) - app.Get("/set", func(ctx iris.Context) { - s := sess.Start(ctx) - //set session values - s.Set("name", "iris") - - //test if setted here - ctx.Writef("All ok session setted to: %s", s.GetString("name")) - }) - - app.Get("/set/{key}/{value}", func(ctx iris.Context) { - key, value := ctx.Params().Get("key"), ctx.Params().Get("value") - s := sess.Start(ctx) - // set session values - s.Set(key, value) - - // test if setted here - ctx.Writef("All ok session setted to: %s", s.GetString(key)) - }) - - app.Get("/get", func(ctx iris.Context) { - // get a specific key, as string, if no found returns just an empty string - name := sess.Start(ctx).GetString("name") - - ctx.Writef("The name on the /set was: %s", name) - }) - - app.Get("/get/{key}", func(ctx iris.Context) { - // get a specific key, as string, if no found returns just an empty string - name := sess.Start(ctx).GetString(ctx.Params().Get("key")) - - ctx.Writef("The name on the /set was: %s", name) - }) - - app.Get("/delete", func(ctx iris.Context) { - // delete a specific key - sess.Start(ctx).Delete("name") - }) - - app.Get("/clear", func(ctx iris.Context) { - // removes all entries - sess.Start(ctx).Clear() - }) - - app.Get("/destroy", func(ctx iris.Context) { - //destroy, removes the entire session data and cookie - sess.Destroy(ctx) - }) - - app.Get("/update", func(ctx iris.Context) { - // updates expire date with a new date - sess.ShiftExpiration(ctx) - }) - - app.Run(iris.Addr(":8080")) -} diff --git a/_examples/sessions/database/redis/main.go b/_examples/sessions/database/redis/main.go index 796c5a49..4ed4be1a 100644 --- a/_examples/sessions/database/redis/main.go +++ b/_examples/sessions/database/redis/main.go @@ -10,6 +10,8 @@ import ( "github.com/kataras/iris/sessions/sessiondb/redis/service" ) +// tested with redis version 3.0.503. +// for windows see: https://github.com/ServiceStack/redis-windows func main() { // replace with your running redis' server settings: db := redis.New(service.Config{ @@ -27,7 +29,12 @@ func main() { db.Close() }) - sess := sessions.New(sessions.Config{Cookie: "sessionscookieid", Expires: 45 * time.Minute}) + defer db.Close() // close the database connection if application errored. + + sess := sessions.New(sessions.Config{ + Cookie: "sessionscookieid", + Expires: 45 * time.Minute}, // <=0 means unlimited life. Defaults to 0. + ) // // IMPORTANT: @@ -46,13 +53,30 @@ func main() { s.Set("name", "iris") //test if setted here - ctx.Writef("All ok session setted to: %s", s.GetString("name")) + ctx.Writef("All ok session value of the 'name' is: %s", s.GetString("name")) + }) + + app.Get("/set/{key}/{value}", func(ctx iris.Context) { + key, value := ctx.Params().Get("key"), ctx.Params().Get("value") + s := sess.Start(ctx) + // set session values + s.Set(key, value) + + // test if setted here + ctx.Writef("All ok session value of the '%s' is: %s", key, s.GetString(key)) }) app.Get("/get", func(ctx iris.Context) { // get a specific key, as string, if no found returns just an empty string name := sess.Start(ctx).GetString("name") + ctx.Writef("The 'name' on the /set was: %s", name) + }) + + app.Get("/get/{key}", func(ctx iris.Context) { + // get a specific key, as string, if no found returns just an empty string + name := sess.Start(ctx).GetString(ctx.Params().Get("key")) + ctx.Writef("The name on the /set was: %s", name) }) @@ -76,5 +100,5 @@ func main() { sess.ShiftExpiration(ctx) }) - app.Run(iris.Addr(":8080")) + app.Run(iris.Addr(":8080"), iris.WithoutServerError(iris.ErrServerClosed)) } diff --git a/core/netutil/client.go b/core/netutil/client.go index 4fc18c49..43ca41c3 100644 --- a/core/netutil/client.go +++ b/core/netutil/client.go @@ -9,7 +9,7 @@ import ( ) // Client returns a new http.Client using -// the "timeout" for open connection and read-write operations. +// the "timeout" for open connection. func Client(timeout time.Duration) *http.Client { transport := http.Transport{ Dial: func(network string, addr string) (net.Conn, error) { @@ -18,9 +18,6 @@ func Client(timeout time.Duration) *http.Client { golog.Debugf("%v", err) return nil, err } - if err = conn.SetDeadline(time.Now().Add(timeout)); err != nil { - golog.Debugf("%v", err) - } return conn, err }, } diff --git a/sessions/database.go b/sessions/database.go index 73eb2ea1..956ff6cd 100644 --- a/sessions/database.go +++ b/sessions/database.go @@ -1,154 +1,93 @@ package sessions import ( - "bytes" - "encoding/gob" - "io" + "sync" + "time" "github.com/kataras/iris/core/memstore" ) -func init() { - gob.Register(RemoteStore{}) -} - // Database is the interface which all session databases should implement // By design it doesn't support any type of cookie session like other frameworks. // I want to protect you, believe me. // The scope of the database is to store somewhere the sessions in order to // keep them after restarting the server, nothing more. // -// Synchronization are made automatically, you can register more than one session database -// but the first non-empty Load return data will be used as the session values. +// Synchronization are made automatically, you can register one using `UseDatabase`. // -// -// Note: Expiration on Load is up to the database, meaning that: -// the database can decide how to retrieve and parse the expiration datetime -// -// I'll try to explain you the flow: -// -// .Start -> if session database attached then load from that storage and save to the memory, otherwise load from memory. The load from database is done once on the initialize of each session. -// .Get (important) -> load from memory, -// if database attached then it already loaded the values -// from database on the .Start action, so it will -// retrieve the data from the memory (fast) -// .Set -> set to the memory, if database attached then update the storage -// .Delete -> clear from memory, if database attached then update the storage -// .Destroy -> destroy from memory and client cookie, -// if database attached then update the storage with empty values, -// empty values means delete the storage with that specific session id. -// Using everything else except memory is slower than memory but database is -// fetched once at each session and its updated on every Set, Delete, -// Destroy at call-time. -// All other external sessions managers out there work different than Iris one as far as I know, -// you may find them more suited to your application, it depends. +// Look the `sessiondb` folder for databases implementations. type Database interface { - Load(sid string) RemoteStore - Sync(p SyncPayload) + // Acquire receives a session's lifetime from the database, + // if the return value is LifeTime{} then the session manager sets the life time based on the expiration duration lives in configuration. + Acquire(sid string, expires time.Duration) LifeTime + // Set sets a key value of a specific session. + // The "immutable" input argument depends on the store, it may not implement it at all. + Set(sid string, lifetime LifeTime, key string, value interface{}, immutable bool) + // Get retrieves a session value based on the key. + Get(sid string, key string) interface{} + // Visit loops through all session keys and values. + Visit(sid string, cb func(key string, value interface{})) + // Len returns the length of the session's entries (keys). + Len(sid string) int + // Delete removes a session key value based on its key. + Delete(sid string, key string) (deleted bool) + // Clear removes all session key values but it keeps the session entry. + Clear(sid string) + // Release destroys the session, it clears and removes the session entry, + // session manager will create a new session ID on the next request after this call. + Release(sid string) } -// New Idea, it should work faster for the most databases needs -// the only minus is that the databases is coupled with this package, they -// should import the kataras/iris/sessions package, but we don't use any -// database by-default so that's ok here. - -// Action reports the specific action that the memory store -// sends to the database. -type Action uint32 - -const ( - // ActionCreate occurs when add a key-value pair - // on the database session entry for the first time. - ActionCreate Action = iota - // ActionInsert occurs when add a key-value pair - // on the database session entry. - ActionInsert - // ActionUpdate occurs when modify an existing key-value pair - // on the database session entry. - ActionUpdate - // ActionDelete occurs when delete a specific value from - // a specific key from the database session entry. - ActionDelete - // ActionClear occurs when clear all values but keep the database session entry. - ActionClear - // ActionDestroy occurs when destroy, - // destroy is the action when clear all and remove the session entry from the database. - ActionDestroy -) - -// SyncPayload reports the state of the session inside a database sync action. -type SyncPayload struct { - SessionID string - - Action Action - // on insert it contains the new key and the value - // on update it contains the existing key and the new value - // on delete it contains the key (the value is nil) - // on clear it contains nothing (empty key, value is nil) - // on destroy it contains nothing (empty key, value is nil) - Value memstore.Entry - // Store contains the whole memory store, this store - // contains the current, updated from memory calls, - // session data (keys and values). This way - // the database has access to the whole session's data - // every time. - Store RemoteStore +type mem struct { + values map[string]*memstore.Store + mu sync.RWMutex } -func newSyncPayload(session *Session, action Action) SyncPayload { - return SyncPayload{ - SessionID: session.sid, - Action: action, - Store: RemoteStore{ - Values: session.values, - Lifetime: session.lifetime, - }, - } +var _ Database = (*mem)(nil) + +func newMemDB() Database { return &mem{values: make(map[string]*memstore.Store)} } + +func (s *mem) Acquire(sid string, expires time.Duration) LifeTime { + s.mu.Lock() + s.values[sid] = new(memstore.Store) + s.mu.Unlock() + return LifeTime{} } -func syncDatabases(databases []Database, payload SyncPayload) { - for i, n := 0, len(databases); i < n; i++ { - databases[i].Sync(payload) - } +// immutable depends on the store, it may not implement it at all. +func (s *mem) Set(sid string, lifetime LifeTime, key string, value interface{}, immutable bool) { + s.mu.RLock() + s.values[sid].Save(key, value, immutable) + s.mu.RUnlock() } -// RemoteStore is a helper which is a wrapper -// for the store, it can be used as the session "table" which will be -// saved to the session database. -type RemoteStore struct { - // Values contains the whole memory store, this store - // contains the current, updated from memory calls, - // session data (keys and values). This way - // the database has access to the whole session's data - // every time. - Values memstore.Store - // on insert it contains the expiration datetime - // on update it contains the new expiration datetime(if updated or the old one) - // on delete it will be zero - // on clear it will be zero - // on destroy it will be zero - Lifetime LifeTime +func (s *mem) Get(sid string, key string) interface{} { + return s.values[sid].Get(key) } -// Serialize returns the byte representation of this RemoteStore. -func (s RemoteStore) Serialize() ([]byte, error) { - w := new(bytes.Buffer) - err := encode(s, w) - return w.Bytes(), err +func (s *mem) Visit(sid string, cb func(key string, value interface{})) { + s.values[sid].Visit(cb) } -// encode accepts a store and writes -// as series of bytes to the "w" writer. -func encode(s RemoteStore, w io.Writer) error { - enc := gob.NewEncoder(w) - err := enc.Encode(s) - return err +func (s *mem) Len(sid string) int { + return s.values[sid].Len() } -// DecodeRemoteStore accepts a series of bytes and returns -// the store. -func DecodeRemoteStore(b []byte) (store RemoteStore, err error) { - dec := gob.NewDecoder(bytes.NewBuffer(b)) - err = dec.Decode(&store) +func (s *mem) Delete(sid string, key string) (deleted bool) { + s.mu.RLock() + deleted = s.values[sid].Remove(key) + s.mu.RUnlock() return } + +func (s *mem) Clear(sid string) { + s.mu.RLock() + s.values[sid].Reset() + s.mu.RUnlock() +} + +func (s *mem) Release(sid string) { + s.mu.Lock() + delete(s.values, sid) + s.mu.Unlock() +} diff --git a/sessions/lifetime.go b/sessions/lifetime.go index e87a47c2..1ba6c02a 100644 --- a/sessions/lifetime.go +++ b/sessions/lifetime.go @@ -63,3 +63,9 @@ func (lt *LifeTime) HasExpired() bool { return lt.Time.Before(time.Now()) } + +// DurationUntilExpiration returns the duration until expires, it can return negative number if expired, +// a call to `HasExpired` may be useful before calling this `Dur` function. +func (lt *LifeTime) DurationUntilExpiration() time.Duration { + return time.Until(lt.Time) +} diff --git a/sessions/provider.go b/sessions/provider.go index 460a6983..0a8e559d 100644 --- a/sessions/provider.go +++ b/sessions/provider.go @@ -3,8 +3,6 @@ package sessions import ( "sync" "time" - - "github.com/kataras/iris/core/memstore" ) type ( @@ -14,25 +12,24 @@ type ( // we don't use RWMutex because all actions have read and write at the same action function. // (or write to a *Session's value which is race if we don't lock) // narrow locks are fasters but are useless here. - mu sync.Mutex - sessions map[string]*Session - databases []Database + mu sync.Mutex + sessions map[string]*Session + db Database } ) // newProvider returns a new sessions provider func newProvider() *provider { return &provider{ - sessions: make(map[string]*Session, 0), - databases: make([]Database, 0), + sessions: make(map[string]*Session, 0), + db: newMemDB(), } } -// RegisterDatabase adds a session database -// a session db doesn't have write access +// RegisterDatabase sets a session database. func (p *provider) RegisterDatabase(db Database) { p.mu.Lock() // for any case - p.databases = append(p.databases, db) + p.db = db p.mu.Unlock() } @@ -42,7 +39,8 @@ func (p *provider) newSession(sid string, expires time.Duration) *Session { p.Destroy(sid) } - values, lifetime := p.loadSessionFromDB(sid) + lifetime := p.db.Acquire(sid, expires) + // simple and straight: if !lifetime.IsZero() { // if stored time is not zero @@ -58,89 +56,16 @@ func (p *provider) newSession(sid string, expires time.Duration) *Session { lifetime.Begin(expires, onExpire) } - // I ended up without the need of a duration field on lifetime, - // but these are some of my previous comments, they will be required for any future change. - // - // OK I think go has a bug when gob on embedded time.Time - // even if we `gob.Register(LifeTime)` - // the OriginalDuration is not saved to the gob file and it cannot be retrieved, it's always 0. - // But if we do not embed the `time.Time` inside the `LifeTime` then - // it's working. - // i.e type LifeTime struct { time.Time; OriginalDuration time.Duration} -> this doesn't - // type LifeTime struct {Time time.Time; OriginalDuration time.Duration} -> this works - // So we have two options: - // 1. don't embed the time.Time -> we will have to use lifetime.Time to get its functions, which doesn't seems right to me - // 2. embed the time.Time and compare their times with `lifetime.After(time.Now().Add(expires))`, it seems right but it - // should be slower. - // - // I'll use the 1. and put some common time.Time functions, like After, IsZero on the `LifeTime` type too. - // - // if db exists but its lifetime is bigger than the expires (very raire, - // the source code should be compatible with the databases, - // should we print a warning to the user? it is his/her fault - // use the database's lifetime or the configurated? - // if values.Len() > 0 && lifetime.OriginalDuration != expires { - // golog.Warnf(`session database: stored expire time(dur=%d) is differnet than the configuration(dur=%d) - // application will use the configurated one`, lifetime.OriginalDuration, expires) - // lifetime.Reset(expires) - // } - sess := &Session{ sid: sid, provider: p, - values: values, flashes: make(map[string]*flashMessage), - lifetime: lifetime, + Lifetime: lifetime, } return sess } -func (p *provider) loadSessionFromDB(sid string) (memstore.Store, LifeTime) { - var ( - store memstore.Store - lifetime LifeTime - firstValidIdx = 1 - ) - - for i, n := 0, len(p.databases); i < n; i++ { - storeDB := p.databases[i].Load(sid) - if storeDB.Lifetime.HasExpired() { // if expired then skip this db - firstValidIdx++ - continue - } - - if lifetime.IsZero() { - // update the lifetime to the most valid - lifetime = storeDB.Lifetime - } - - if n == firstValidIdx { - // if one database then set the store as it is - store = storeDB.Values - } else { - // else append this database's key-value pairs - // to the store - storeDB.Values.Visit(func(key string, value interface{}) { - store.Save(key, value, false) - }) - } - } - - // default to memstore if no other store given. - // if store == nil { - // store = &memstore.Store{} - // } - - // Note: if one database and it's being expired then the lifetime will be zero(unlimited) - // this by itself is wrong but on the `newSession` we make check of this case too and update the lifetime - // if the configuration has expiration registered. - - /// TODO: bug on destroy doesn't being remove the file - // we will have to see it, it's not db's problem it's here on provider destroy or lifetime onExpire. - return store, lifetime -} - // Init creates the session and returns it func (p *provider) Init(sid string, expires time.Duration) *Session { newSession := p.newSession(sid, expires) @@ -165,7 +90,7 @@ func (p *provider) UpdateExpiration(sid string, expires time.Duration) bool { return false } - sess.lifetime.Shift(expires) + sess.Lifetime.Shift(expires) return true } @@ -207,7 +132,5 @@ func (p *provider) DestroyAll() { func (p *provider) deleteSession(sess *Session) { delete(p.sessions, sess.sid) - if len(p.databases) > 0 { - syncDatabases(p.databases, newSyncPayload(sess, ActionDestroy)) - } + p.db.Release(sess.sid) } diff --git a/sessions/session.go b/sessions/session.go index 4b19ee2b..fc7ded6d 100644 --- a/sessions/session.go +++ b/sessions/session.go @@ -5,7 +5,6 @@ import ( "sync" "github.com/kataras/iris/core/errors" - "github.com/kataras/iris/core/memstore" ) type ( @@ -17,10 +16,9 @@ type ( Session struct { sid string isNew bool - values memstore.Store // here are the session's values, managed by memstore. flashes map[string]*flashMessage mu sync.RWMutex // for flashes. - lifetime LifeTime + Lifetime LifeTime provider *provider } @@ -56,7 +54,7 @@ func (s *Session) IsNew() bool { // Get returns a value based on its "key". func (s *Session) Get(key string) interface{} { - return s.values.Get(key) + return s.provider.db.Get(s.sid, key) } // when running on the session manager removes any 'old' flash messages. @@ -366,9 +364,9 @@ func (s *Session) GetBooleanDefault(key string, defaultValue bool) bool { // GetAll returns a copy of all session's values. func (s *Session) GetAll() map[string]interface{} { - items := make(map[string]interface{}, s.values.Len()) + items := make(map[string]interface{}, s.provider.db.Len(s.sid)) s.mu.RLock() - s.values.Visit(func(key string, value interface{}) { + s.provider.db.Visit(s.sid, func(key string, value interface{}) { items[key] = value }) s.mu.RUnlock() @@ -388,39 +386,17 @@ func (s *Session) GetFlashes() map[string]interface{} { return flashes } -// VisitAll loop each one entry and calls the callback function func(key,value) -func (s *Session) VisitAll(cb func(k string, v interface{})) { - s.values.Visit(cb) +// Visit loops each of the entries and calls the callback function func(key, value). +func (s *Session) Visit(cb func(k string, v interface{})) { + s.provider.db.Visit(s.sid, cb) } func (s *Session) set(key string, value interface{}, immutable bool) { - - isFirst := s.values.Len() == 0 - entry, isNew := s.values.Save(key, value, immutable) + s.provider.db.Set(s.sid, s.Lifetime, key, value, immutable) s.mu.Lock() s.isNew = false s.mu.Unlock() - - if len(s.provider.databases) > 0 { - action := ActionCreate // defaults to create, means the first insert. - if !isFirst { - // we could use s.isNew - // which is setted at sessions.go#Start when values are empty - // but here we want the specific key-value pair's state. - if isNew { - action = ActionInsert - } else { - action = ActionUpdate - } - } - - p := newSyncPayload(s, action) - p.Value = entry - - syncDatabases(s.provider.databases, p) - } - } // Set fills the session with an entry "value", based on its "key". @@ -467,17 +443,11 @@ func (s *Session) SetFlash(key string, value interface{}) { // Delete removes an entry by its key, // returns true if actually something was removed. func (s *Session) Delete(key string) bool { - s.mu.Lock() - removed := s.values.Remove(key) + removed := s.provider.db.Delete(s.sid, key) if removed { + s.mu.Lock() s.isNew = false - } - s.mu.Unlock() - - if len(s.provider.databases) > 0 { - p := newSyncPayload(s, ActionDelete) - p.Value = memstore.Entry{Key: key} - syncDatabases(s.provider.databases, p) + s.mu.Unlock() } return removed @@ -493,14 +463,9 @@ func (s *Session) DeleteFlash(key string) { // Clear removes all entries. func (s *Session) Clear() { s.mu.Lock() - s.values.Reset() + s.provider.db.Clear(s.sid) s.isNew = false s.mu.Unlock() - - if len(s.provider.databases) > 0 { - p := newSyncPayload(s, ActionClear) - syncDatabases(s.provider.databases, p) - } } // ClearFlashes removes all flash messages. diff --git a/sessions/sessiondb/badger/database.go b/sessions/sessiondb/badger/database.go index 62552c27..9b0efeaf 100644 --- a/sessions/sessiondb/badger/database.go +++ b/sessions/sessiondb/badger/database.go @@ -3,6 +3,7 @@ package badger import ( "os" "runtime" + "time" "github.com/kataras/golog" "github.com/kataras/iris/core/errors" @@ -25,6 +26,8 @@ type Database struct { Service *badger.DB } +var _ sessions.Database = (*Database)(nil) + // New creates and returns a new badger(key-value file-based) storage // instance based on the "directoryPath". // DirectoryPath should is the directory which the badger database will store the sessions, @@ -32,9 +35,8 @@ type Database struct { // // It will remove any old session files. func New(directoryPath string) (*Database, error) { - if directoryPath == "" { - return nil, errors.New("dir is missing") + return nil, errors.New("directoryPath is missing") } lindex := directoryPath[len(directoryPath)-1] @@ -57,134 +59,180 @@ func New(directoryPath string) (*Database, error) { return nil, err } - return NewFromDB(service) + return NewFromDB(service), nil } // NewFromDB same as `New` but accepts an already-created custom badger connection instead. -func NewFromDB(service *badger.DB) (*Database, error) { - if service == nil { - return nil, errors.New("underline database is missing") - } - +func NewFromDB(service *badger.DB) *Database { db := &Database{Service: service} runtime.SetFinalizer(db, closeDB) - return db, db.Cleanup() -} - -// Cleanup removes any invalid(have expired) session entries, -// it's being called automatically on `New` as well. -func (db *Database) Cleanup() (err error) { - rep := errors.NewReporter() - - txn := db.Service.NewTransaction(true) - defer txn.Commit(nil) - - iter := txn.NewIterator(badger.DefaultIteratorOptions) - defer iter.Close() - - for iter.Rewind(); iter.Valid(); iter.Next() { - // Remember that the contents of the returned slice should not be modified, and - // only valid until the next call to Next. - item := iter.Item() - b, err := item.Value() - - if rep.AddErr(err) { - continue - } - - storeDB, err := sessions.DecodeRemoteStore(b) - if rep.AddErr(err) { - continue - } - - if storeDB.Lifetime.HasExpired() { - if err := txn.Delete(item.Key()); err != nil { - rep.AddErr(err) - } - } - } - - return rep.Return() -} - -// Async is DEPRECATED -// if it was true then it could use different to update the back-end storage, now it does nothing. -func (db *Database) Async(useGoRoutines bool) *Database { return db } -// Load loads the sessions from the badger(key-value file-based) session storage. -func (db *Database) Load(sid string) (storeDB sessions.RemoteStore) { +// Acquire receives a session's lifetime from the database, +// if the return value is LifeTime{} then the session manager sets the life time based on the expiration duration lives in configuration. +func (db *Database) Acquire(sid string, expires time.Duration) sessions.LifeTime { + txn := db.Service.NewTransaction(true) + defer txn.Commit(nil) + bsid := []byte(sid) - - txn := db.Service.NewTransaction(false) - defer txn.Discard() - item, err := txn.Get(bsid) + if err == nil { + // found, return the expiration. + return sessions.LifeTime{Time: time.Unix(int64(item.ExpiresAt()), 0)} + } + + // not found, create an entry with ttl and return an empty lifetime, session manager will do its job. if err != nil { - // Key not found, don't report this, session manager will create a new session as it should. + if err == badger.ErrKeyNotFound { + // create it and set the expiration, we don't care about the value there. + err = txn.SetWithTTL(bsid, bsid, expires) + } + } + + if err != nil { + golog.Error(err) + } + + return sessions.LifeTime{} // session manager will handle the rest. +} + +var delim = byte('*') + +func makeKey(sid, key string) []byte { + return append([]byte(sid), append([]byte(key), delim)...) +} + +// Set sets a key value of a specific session. +// Ignore the "immutable". +func (db *Database) Set(sid string, lifetime sessions.LifeTime, key string, value interface{}, immutable bool) { + valueBytes, err := sessions.DefaultTranscoder.Marshal(value) + if err != nil { + golog.Error(err) return } - b, err := item.Value() + err = db.Service.Update(func(txn *badger.Txn) error { + return txn.SetWithTTL(makeKey(sid, key), valueBytes, lifetime.DurationUntilExpiration()) + // return txn.Set(makeKey(sid, key), valueBytes) + }) if err != nil { - golog.Errorf("error while trying to get the serialized session(%s) from the remote store: %v", sid, err) - return + golog.Error(err) } +} - storeDB, err = sessions.DecodeRemoteStore(b) // decode the whole value, as a remote store - if err != nil { - golog.Errorf("error while trying to load from the remote store: %v", err) +// Get retrieves a session value based on the key. +func (db *Database) Get(sid string, key string) (value interface{}) { + err := db.Service.View(func(txn *badger.Txn) error { + item, err := txn.Get(makeKey(sid, key)) + if err != nil { + return err + } + // item.ValueCopy + valueBytes, err := item.Value() + if err != nil { + return err + } + + return sessions.DefaultTranscoder.Unmarshal(valueBytes, &value) + }) + + if err != nil && err != badger.ErrKeyNotFound { + golog.Error(err) + return nil } return } -// Sync syncs the database with the session's (memory) store. -func (db *Database) Sync(p sessions.SyncPayload) { - db.sync(p) -} +// Visit loops through all session keys and values. +func (db *Database) Visit(sid string, cb func(key string, value interface{})) { + prefix := append([]byte(sid), delim) -func (db *Database) sync(p sessions.SyncPayload) { - bsid := []byte(p.SessionID) + txn := db.Service.NewTransaction(false) + defer txn.Discard() - if p.Action == sessions.ActionDestroy { - if err := db.destroy(bsid); err != nil { - golog.Errorf("error while destroying a session(%s) from badger: %v", - p.SessionID, err) + iter := txn.NewIterator(badger.DefaultIteratorOptions) + defer iter.Close() + + for iter.Rewind(); iter.ValidForPrefix(prefix); iter.Next() { + item := iter.Item() + valueBytes, err := item.Value() + if err != nil { + golog.Error(err) + continue } - return - } - s, err := p.Store.Serialize() - if err != nil { - golog.Errorf("error while serializing the remote store: %v", err) - } + var value interface{} + if err = sessions.DefaultTranscoder.Unmarshal(valueBytes, &value); err != nil { + golog.Error(err) + continue + } - txn := db.Service.NewTransaction(true) - - err = txn.Set(bsid, s) - if err != nil { - txn.Discard() - golog.Errorf("error while trying to save the session(%s) to the database: %v", p.SessionID, err) - return - } - if err := txn.Commit(nil); err != nil { // Commit will call the Discard automatically. - golog.Errorf("error while committing the session(%s) changes to the database: %v", p.SessionID, err) + cb(string(item.Key()), value) } } -func (db *Database) destroy(bsid []byte) error { - txn := db.Service.NewTransaction(true) +var iterOptionsNoValues = badger.IteratorOptions{ + PrefetchValues: false, + PrefetchSize: 100, + Reverse: false, + AllVersions: false, +} - err := txn.Delete(bsid) - if err != nil { - return err +// Len returns the length of the session's entries (keys). +func (db *Database) Len(sid string) (n int) { + prefix := append([]byte(sid), delim) + + txn := db.Service.NewTransaction(false) + iter := txn.NewIterator(iterOptionsNoValues) + + for iter.Rewind(); iter.ValidForPrefix(prefix); iter.Next() { + n++ } - return txn.Commit(nil) + iter.Close() + txn.Discard() + return +} + +// Delete removes a session key value based on its key. +func (db *Database) Delete(sid string, key string) (deleted bool) { + txn := db.Service.NewTransaction(true) + err := txn.Delete(makeKey(sid, key)) + if err != nil { + golog.Error(err) + } + txn.Commit(nil) + return err == nil +} + +// Clear removes all session key values but it keeps the session entry. +func (db *Database) Clear(sid string) { + prefix := append([]byte(sid), delim) + + txn := db.Service.NewTransaction(true) + defer txn.Commit(nil) + + iter := txn.NewIterator(iterOptionsNoValues) + defer iter.Close() + + for iter.Rewind(); iter.ValidForPrefix(prefix); iter.Next() { + txn.Delete(iter.Item().Key()) + } +} + +// Release destroys the session, it clears and removes the session entry, +// session manager will create a new session ID on the next request after this call. +func (db *Database) Release(sid string) { + // clear all $sid-$key. + db.Clear(sid) + // and remove the $sid. + txn := db.Service.NewTransaction(true) + txn.Delete([]byte(sid)) + txn.Commit(nil) } // Close shutdowns the badger connection. diff --git a/sessions/sessiondb/boltdb/database.go b/sessions/sessiondb/boltdb/database.go deleted file mode 100644 index d6c1b16b..00000000 --- a/sessions/sessiondb/boltdb/database.go +++ /dev/null @@ -1,230 +0,0 @@ -package boltdb - -import ( - "bytes" - "os" - "path/filepath" - "runtime" - "time" - - "github.com/coreos/bbolt" - "github.com/kataras/golog" - "github.com/kataras/iris/core/errors" - "github.com/kataras/iris/sessions" -) - -// DefaultFileMode used as the default database's "fileMode" -// for creating the sessions directory path, opening and write -// the session boltdb(file-based) storage. -var ( - DefaultFileMode = 0755 -) - -// Database the BoltDB(file-based) session storage. -type Database struct { - table []byte - // Service is the underline BoltDB database connection, - // it's initialized at `New` or `NewFromDB`. - // Can be used to get stats. - Service *bolt.DB -} - -var ( - // ErrOptionsMissing returned on `New` when path or tableName are empty. - ErrOptionsMissing = errors.New("required options are missing") -) - -// New creates and returns a new BoltDB(file-based) storage -// instance based on the "path". -// Path should include the filename and the directory(aka fullpath), i.e sessions/store.db. -// -// It will remove any old session files. -func New(path string, fileMode os.FileMode, bucketName string) (*Database, error) { - - if path == "" || bucketName == "" { - return nil, ErrOptionsMissing - } - - if fileMode <= 0 { - fileMode = os.FileMode(DefaultFileMode) - } - - // create directories if necessary - if err := os.MkdirAll(filepath.Dir(path), fileMode); err != nil { - golog.Errorf("error while trying to create the necessary directories for %s: %v", path, err) - return nil, err - } - - service, err := bolt.Open(path, 0600, - &bolt.Options{Timeout: 15 * time.Second}, - ) - - if err != nil { - golog.Errorf("unable to initialize the BoltDB-based session database: %v", err) - return nil, err - } - - return NewFromDB(service, bucketName) -} - -// NewFromDB same as `New` but accepts an already-created custom boltdb connection instead. -func NewFromDB(service *bolt.DB, bucketName string) (*Database, error) { - if bucketName == "" { - return nil, ErrOptionsMissing - } - bucket := []byte(bucketName) - - service.Update(func(tx *bolt.Tx) (err error) { - _, err = tx.CreateBucketIfNotExists(bucket) - return - }) - - db := &Database{table: bucket, Service: service} - - runtime.SetFinalizer(db, closeDB) - return db, db.Cleanup() -} - -// Cleanup removes any invalid(have expired) session entries, -// it's being called automatically on `New` as well. -func (db *Database) Cleanup() error { - err := db.Service.Update(func(tx *bolt.Tx) error { - b := db.getBucket(tx) - c := b.Cursor() - for k, v := c.First(); k != nil; k, v = c.Next() { - if len(k) == 0 { // empty key, continue to the next pair - continue - } - - storeDB, err := sessions.DecodeRemoteStore(v) - if err != nil { - continue - } - - if storeDB.Lifetime.HasExpired() { - if err := c.Delete(); err != nil { - golog.Warnf("troubles when cleanup a session remote store from BoltDB: %v", err) - } - } - } - - return nil - }) - - return err -} - -// Async is DEPRECATED -// if it was true then it could use different to update the back-end storage, now it does nothing. -func (db *Database) Async(useGoRoutines bool) *Database { - return db -} - -// Load loads the sessions from the BoltDB(file-based) session storage. -func (db *Database) Load(sid string) (storeDB sessions.RemoteStore) { - bsid := []byte(sid) - err := db.Service.View(func(tx *bolt.Tx) (err error) { - // db.getSessBucket(tx, sid) - b := db.getBucket(tx) - c := b.Cursor() - for k, v := c.First(); k != nil; k, v = c.Next() { - if len(k) == 0 { // empty key, continue to the next pair - continue - } - - if bytes.Equal(k, bsid) { // session id should be the name of the key-value pair - storeDB, err = sessions.DecodeRemoteStore(v) // decode the whole value, as a remote store - break - } - } - return - }) - - if err != nil { - golog.Errorf("error while trying to load from the remote store: %v", err) - } - - return -} - -// Sync syncs the database with the session's (memory) store. -func (db *Database) Sync(p sessions.SyncPayload) { - db.sync(p) -} - -func (db *Database) sync(p sessions.SyncPayload) { - bsid := []byte(p.SessionID) - - if p.Action == sessions.ActionDestroy { - if err := db.destroy(bsid); err != nil { - golog.Errorf("error while destroying a session(%s) from boltdb: %v", - p.SessionID, err) - } - return - } - - s, err := p.Store.Serialize() - if err != nil { - golog.Errorf("error while serializing the remote store: %v", err) - } - - err = db.Service.Update(func(tx *bolt.Tx) error { - return db.getBucket(tx).Put(bsid, s) - }) - if err != nil { - golog.Errorf("error while writing the session bucket: %v", err) - } -} - -func (db *Database) destroy(bsid []byte) error { - return db.Service.Update(func(tx *bolt.Tx) error { - return db.getBucket(tx).Delete(bsid) - }) -} - -// we store the whole data to the key-value pair of the root bucket -// so we don't need a separate bucket for each session -// this method could be faster if we had large data to store -// but with sessions we recommend small amount of data, so the method finally chosen -// is faster (decode/encode the whole store + lifetime and return it as it's) -// -// func (db *Database) getSessBucket(tx *bolt.Tx, sid string) (*bolt.Bucket, error) { -// table, err := db.getBucket(tx).CreateBucketIfNotExists([]byte(sid)) -// return table, err -// } - -func (db *Database) getBucket(tx *bolt.Tx) *bolt.Bucket { - return tx.Bucket(db.table) -} - -// Len reports the number of sessions that are stored to the this BoltDB table. -func (db *Database) Len() (num int) { - db.Service.View(func(tx *bolt.Tx) error { - // Assume bucket exists and has keys - b := db.getBucket(tx) - if b == nil { - return nil - } - - b.ForEach(func([]byte, []byte) error { - num++ - return nil - }) - return nil - }) - return -} - -// Close shutdowns the BoltDB connection. -func (db *Database) Close() error { - return closeDB(db) -} - -func closeDB(db *Database) error { - err := db.Service.Close() - if err != nil { - golog.Warnf("closing the BoltDB connection: %v", err) - } - - return err -} diff --git a/sessions/sessiondb/file/database.go b/sessions/sessiondb/file/database.go deleted file mode 100644 index bd64aab8..00000000 --- a/sessions/sessiondb/file/database.go +++ /dev/null @@ -1,203 +0,0 @@ -package file - -import ( - "io/ioutil" - "os" - "path/filepath" - - "github.com/kataras/golog" - "github.com/kataras/iris/core/errors" - "github.com/kataras/iris/sessions" -) - -// DefaultFileMode used as the default database's "fileMode" -// for creating the sessions directory path, opening and write the session file. -var ( - DefaultFileMode = 0755 -) - -// Database is the basic file-storage session database. -// -// What it does -// It removes old(expired) session files, at init (`Cleanup`). -// It creates a session file on the first inserted key-value session data. -// It removes a session file on destroy. -// It sync the session file to the session's memstore on any other action (insert, delete, clear). -// It automatically remove the session files on runtime when a session is expired. -// -// Remember: sessions are not a storage for large data, everywhere: on any platform on any programming language. -type Database struct { - dir string - fileMode os.FileMode // defaults to DefaultFileMode if missing. -} - -// New creates and returns a new file-storage database instance based on the "directoryPath". -// DirectoryPath should is the directory which the leveldb database will store the sessions, -// i.e ./sessions/ -// -// It will remove any old session files. -func New(directoryPath string, fileMode os.FileMode) (*Database, error) { - lindex := directoryPath[len(directoryPath)-1] - if lindex != os.PathSeparator && lindex != '/' { - directoryPath += string(os.PathSeparator) - } - - if fileMode <= 0 { - fileMode = os.FileMode(DefaultFileMode) - } - - // create directories if necessary - if err := os.MkdirAll(directoryPath, fileMode); err != nil { - return nil, err - } - - db := &Database{dir: directoryPath, fileMode: fileMode} - return db, db.Cleanup() -} - -// Cleanup removes any invalid(have expired) session files, it's being called automatically on `New` as well. -func (db *Database) Cleanup() error { - return filepath.Walk(db.dir, func(path string, info os.FileInfo, err error) error { - if info.IsDir() { - return nil - } - sessPath := path - storeDB, _ := db.load(sessPath) // we don't care about errors here, the file may be not a session a file at all. - if storeDB.Lifetime.HasExpired() { - os.Remove(path) - } - return nil - }) -} - -// FileMode for creating the sessions directory path, opening and write the session file. -// -// Defaults to 0755. -func (db *Database) FileMode(fileMode uint32) *Database { - db.fileMode = os.FileMode(fileMode) - return db -} - -// Async is DEPRECATED -// if it was true then it could use different to update the back-end storage, now it does nothing. -func (db *Database) Async(useGoRoutines bool) *Database { - return db -} - -func (db *Database) sessPath(sid string) string { - return filepath.Join(db.dir, sid) -} - -// Load loads the values from the storage and returns them -func (db *Database) Load(sid string) sessions.RemoteStore { - sessPath := db.sessPath(sid) - store, err := db.load(sessPath) - if err != nil { - golog.Error(err.Error()) - } - return store -} - -func (db *Database) load(fileName string) (storeDB sessions.RemoteStore, loadErr error) { - f, err := os.OpenFile(fileName, os.O_RDONLY, db.fileMode) - - if err != nil { - // we don't care if filepath doesn't exists yet, it will be created later on. - return - } - - defer f.Close() - - contents, err := ioutil.ReadAll(f) - - if err != nil { - loadErr = errors.New("error while reading the session file's data: %v").Format(err) - return - } - - storeDB, err = sessions.DecodeRemoteStore(contents) - - if err != nil { // we care for this error only - loadErr = errors.New("load error: %v").Format(err) - return - } - - return -} - -// Sync syncs the database. -func (db *Database) Sync(p sessions.SyncPayload) { - db.sync(p) -} - -func (db *Database) sync(p sessions.SyncPayload) { - - // if destroy then remove the file from the disk - if p.Action == sessions.ActionDestroy { - if err := db.destroy(p.SessionID); err != nil { - golog.Errorf("error while destroying and removing the session file: %v", err) - } - return - } - - if err := db.override(p.SessionID, p.Store); err != nil { - golog.Errorf("error while writing the session file: %v", err) - } - -} - -// good idea but doesn't work, it is not just an array of entries -// which can be appended with the gob...anyway session data should be small so we don't have problem -// with that: - -// on insert new data, it appends to the file -// func (db *Database) insert(sid string, entry memstore.Entry) error { -// f, err := os.OpenFile( -// db.sessPath(sid), -// os.O_WRONLY|os.O_CREATE|os.O_RDWR|os.O_APPEND, -// db.fileMode, -// ) - -// if err != nil { -// return err -// } - -// if _, err := f.Write(serializeEntry(entry)); err != nil { -// f.Close() -// return err -// } - -// return f.Close() -// } - -// removes all entries but keeps the file. -// func (db *Database) clearAll(sid string) error { -// return ioutil.WriteFile( -// db.sessPath(sid), -// []byte{}, -// db.fileMode, -// ) -// } - -// on update, remove and clear, it re-writes the file to the current values(may empty). -func (db *Database) override(sid string, store sessions.RemoteStore) error { - s, err := store.Serialize() - if err != nil { - return err - } - return ioutil.WriteFile( - db.sessPath(sid), - s, - db.fileMode, - ) -} - -// on destroy, it removes the file -func (db *Database) destroy(sid string) error { - return db.expireSess(sid) -} - -func (db *Database) expireSess(sid string) error { - sessPath := db.sessPath(sid) - return os.Remove(sessPath) -} diff --git a/sessions/sessiondb/leveldb/database.go b/sessions/sessiondb/leveldb/database.go deleted file mode 100644 index b13c772f..00000000 --- a/sessions/sessiondb/leveldb/database.go +++ /dev/null @@ -1,183 +0,0 @@ -package leveldb - -import ( - "bytes" - "runtime" - - "github.com/kataras/golog" - "github.com/kataras/iris/core/errors" - "github.com/kataras/iris/sessions" - - "github.com/syndtr/goleveldb/leveldb" - "github.com/syndtr/goleveldb/leveldb/opt" -) - -var ( - // Options used to open the leveldb database, defaults to leveldb's default values. - Options = &opt.Options{} - // WriteOptions used to put and delete, defaults to leveldb's default values. - WriteOptions = &opt.WriteOptions{} - // ReadOptions used to iterate over the database, defaults to leveldb's default values. - ReadOptions = &opt.ReadOptions{} -) - -// Database the LevelDB(file-based) session storage. -type Database struct { - // Service is the underline LevelDB database connection, - // it's initialized at `New` or `NewFromDB`. - // Can be used to get stats. - Service *leveldb.DB -} - -// New creates and returns a new LevelDB(file-based) storage -// instance based on the "directoryPath". -// DirectoryPath should is the directory which the leveldb database will store the sessions, -// i.e ./sessions/ -// -// It will remove any old session files. -func New(directoryPath string) (*Database, error) { - - if directoryPath == "" { - return nil, errors.New("dir is missing") - } - - // Second parameter is a "github.com/syndtr/goleveldb/leveldb/opt.Options{}" - // user can change the `Options` or create the sessiondb via `NewFromDB` - // if wants to use a customized leveldb database - // or an existing one, we don't require leveldb options at the constructor. - // - // The leveldb creates the directories, if necessary. - service, err := leveldb.OpenFile(directoryPath, Options) - - if err != nil { - golog.Errorf("unable to initialize the LevelDB-based session database: %v", err) - return nil, err - } - - return NewFromDB(service) -} - -// NewFromDB same as `New` but accepts an already-created custom leveldb connection instead. -func NewFromDB(service *leveldb.DB) (*Database, error) { - if service == nil { - return nil, errors.New("underline database is missing") - } - - db := &Database{Service: service} - - runtime.SetFinalizer(db, closeDB) - return db, db.Cleanup() -} - -// Cleanup removes any invalid(have expired) session entries, -// it's being called automatically on `New` as well. -func (db *Database) Cleanup() error { - iter := db.Service.NewIterator(nil, ReadOptions) - for iter.Next() { - // Remember that the contents of the returned slice should not be modified, and - // only valid until the next call to Next. - k := iter.Key() - - if len(k) > 0 { - v := iter.Value() - storeDB, err := sessions.DecodeRemoteStore(v) - if err != nil { - continue - } - - if storeDB.Lifetime.HasExpired() { - if err := db.Service.Delete(k, WriteOptions); err != nil { - golog.Warnf("troubles when cleanup a session remote store from LevelDB: %v", err) - } - } - } - - } - iter.Release() - return iter.Error() -} - -// Async is DEPRECATED -// if it was true then it could use different to update the back-end storage, now it does nothing. -func (db *Database) Async(useGoRoutines bool) *Database { - return db -} - -// Load loads the sessions from the LevelDB(file-based) session storage. -func (db *Database) Load(sid string) (storeDB sessions.RemoteStore) { - bsid := []byte(sid) - - iter := db.Service.NewIterator(nil, ReadOptions) - for iter.Next() { - // Remember that the contents of the returned slice should not be modified, and - // only valid until the next call to Next. - k := iter.Key() - - if len(k) > 0 { - v := iter.Value() - if bytes.Equal(k, bsid) { // session id should be the name of the key-value pair - store, err := sessions.DecodeRemoteStore(v) // decode the whole value, as a remote store - if err != nil { - golog.Errorf("error while trying to load from the remote store: %v", err) - } else { - storeDB = store - } - break - } - } - - } - - iter.Release() - if err := iter.Error(); err != nil { - golog.Errorf("error while trying to iterate over the database: %v", err) - } - - return -} - -// Sync syncs the database with the session's (memory) store. -func (db *Database) Sync(p sessions.SyncPayload) { - db.sync(p) -} - -func (db *Database) sync(p sessions.SyncPayload) { - bsid := []byte(p.SessionID) - - if p.Action == sessions.ActionDestroy { - if err := db.destroy(bsid); err != nil { - golog.Errorf("error while destroying a session(%s) from leveldb: %v", - p.SessionID, err) - } - return - } - - s, err := p.Store.Serialize() - if err != nil { - golog.Errorf("error while serializing the remote store: %v", err) - } - - err = db.Service.Put(bsid, s, WriteOptions) - - if err != nil { - golog.Errorf("error while writing the session(%s) to the database: %v", p.SessionID, err) - } -} - -func (db *Database) destroy(bsid []byte) error { - return db.Service.Delete(bsid, WriteOptions) -} - -// Close shutdowns the LevelDB connection. -func (db *Database) Close() error { - return closeDB(db) -} - -func closeDB(db *Database) error { - err := db.Service.Close() - if err != nil { - golog.Warnf("closing the LevelDB connection: %v", err) - } - - return err -} diff --git a/sessions/sessiondb/redis/database.go b/sessions/sessiondb/redis/database.go index 01c42c18..51981b5a 100644 --- a/sessions/sessiondb/redis/database.go +++ b/sessions/sessiondb/redis/database.go @@ -14,9 +14,17 @@ type Database struct { redis *service.Service } +var _ sessions.Database = (*Database)(nil) + // New returns a new redis database. func New(cfg ...service.Config) *Database { db := &Database{redis: service.New(cfg...)} + db.redis.Connect() + _, err := db.redis.PingPong() + if err != nil { + golog.Debugf("error connecting to redis: %v", err) + return nil + } runtime.SetFinalizer(db, closeDB) return db } @@ -26,73 +34,119 @@ func (db *Database) Config() *service.Config { return db.redis.Config } -// Async is DEPRECATED -// if it was true then it could use different to update the back-end storage, now it does nothing. -func (db *Database) Async(useGoRoutines bool) *Database { - return db +// Acquire receives a session's lifetime from the database, +// if the return value is LifeTime{} then the session manager sets the life time based on the expiration duration lives in configuration. +func (db *Database) Acquire(sid string, expires time.Duration) sessions.LifeTime { + seconds, hasExpiration, found := db.redis.TTL(sid) + if !found { + // not found, create an entry with ttl and return an empty lifetime, session manager will do its job. + if err := db.redis.Set(sid, sid, int64(expires.Seconds())); err != nil { + golog.Debug(err) + } + + return sessions.LifeTime{} // session manager will handle the rest. + } + + if !hasExpiration { + return sessions.LifeTime{} + + } + + return sessions.LifeTime{Time: time.Now().Add(time.Duration(seconds) * time.Second)} } -// Load loads the values to the underline. -func (db *Database) Load(sid string) (storeDB sessions.RemoteStore) { - // values := make(map[string]interface{}) +const delim = "_" - if !db.redis.Connected { //yes, check every first time's session for valid redis connection - db.redis.Connect() - _, err := db.redis.PingPong() - if err != nil { - golog.Errorf("redis database error on connect: %v", err) - return - } +func makeKey(sid, key string) string { + return sid + delim + key +} + +// Set sets a key value of a specific session. +// Ignore the "immutable". +func (db *Database) Set(sid string, lifetime sessions.LifeTime, key string, value interface{}, immutable bool) { + valueBytes, err := sessions.DefaultTranscoder.Marshal(value) + if err != nil { + golog.Error(err) + return } - // fetch the values from this session id and copy-> store them - storeMaybe, err := db.redis.Get(sid) - // exists - if err == nil { - storeB, ok := storeMaybe.([]byte) - if !ok { - golog.Errorf("something wrong, store should be stored as []byte but stored as %#v", storeMaybe) - return - } - - storeDB, err = sessions.DecodeRemoteStore(storeB) // decode the whole value, as a remote store - if err != nil { - golog.Errorf(`error while trying to load session values(%s) from redis: - the retrieved value is not a sessions.RemoteStore type, please report that as bug, that should never occur: %v`, - sid, err) - } + if err = db.redis.Set(makeKey(sid, key), valueBytes, int64(lifetime.DurationUntilExpiration().Seconds())); err != nil { + golog.Debug(err) } +} +// Get retrieves a session value based on the key. +func (db *Database) Get(sid string, key string) (value interface{}) { + db.get(makeKey(sid, key), &value) return } -// Sync syncs the database. -func (db *Database) Sync(p sessions.SyncPayload) { - db.sync(p) -} - -func (db *Database) sync(p sessions.SyncPayload) { - if p.Action == sessions.ActionDestroy { - db.redis.Delete(p.SessionID) - return - } - storeB, err := p.Store.Serialize() +func (db *Database) get(key string, outPtr interface{}) { + data, err := db.redis.Get(key) if err != nil { - golog.Error("error while encoding the remote session store") + // not found. return } - // not expire if zero - seconds := 0 - - if lifetime := p.Store.Lifetime; !lifetime.IsZero() { - seconds = int(lifetime.Sub(time.Now()).Seconds()) + if err = sessions.DefaultTranscoder.Unmarshal(data.([]byte), outPtr); err != nil { + golog.Debugf("unable to unmarshal value of key: '%s': %v", key, err) } - - db.redis.Set(p.SessionID, storeB, seconds) } -// Close shutdowns the redis connection. +func (db *Database) keys(sid string) []string { + keys, err := db.redis.GetKeys(sid + delim) + if err != nil { + golog.Debugf("unable to get all redis keys of session '%s': %v", sid, err) + return nil + } + + return keys +} + +// Visit loops through all session keys and values. +func (db *Database) Visit(sid string, cb func(key string, value interface{})) { + keys := db.keys(sid) + for _, key := range keys { + var value interface{} // new value each time, we don't know what user will do in "cb". + db.get(key, &value) + cb(key, value) + } +} + +// Len returns the length of the session's entries (keys). +func (db *Database) Len(sid string) (n int) { + return len(db.keys(sid)) +} + +// Delete removes a session key value based on its key. +func (db *Database) Delete(sid string, key string) (deleted bool) { + err := db.redis.Delete(makeKey(sid, key)) + if err != nil { + golog.Error(err) + } + return err == nil +} + +// Clear removes all session key values but it keeps the session entry. +func (db *Database) Clear(sid string) { + keys := db.keys(sid) + for _, key := range keys { + if err := db.redis.Delete(key); err != nil { + golog.Debugf("unable to delete session '%s' value of key: '%s': %v", sid, key, err) + } + } +} + +// Release destroys the session, it clears and removes the session entry, +// session manager will create a new session ID on the next request after this call. +func (db *Database) Release(sid string) { + // clear all $sid-$key. + db.Clear(sid) + // and remove the $sid. + db.redis.Delete(sid) +} + +// Close terminates the redis connection. func (db *Database) Close() error { return closeDB(db) } diff --git a/sessions/sessiondb/redis/service/service.go b/sessions/sessiondb/redis/service/service.go index 34d4f8db..ecb059ce 100644 --- a/sessions/sessiondb/redis/service/service.go +++ b/sessions/sessiondb/redis/service/service.go @@ -1,9 +1,10 @@ package service import ( + "fmt" "time" - "github.com/garyburd/redigo/redis" + "github.com/gomodule/redigo/redis" "github.com/kataras/iris/core/errors" ) @@ -44,7 +45,7 @@ func (r *Service) CloseConnection() error { // Set sets a key-value to the redis store. // The expiration is setted by the MaxAgeSeconds. -func (r *Service) Set(key string, value interface{}, secondsLifetime int) (err error) { +func (r *Service) Set(key string, value interface{}, secondsLifetime int64) (err error) { c := r.pool.Get() defer c.Close() if c.Err() != nil { @@ -81,6 +82,23 @@ func (r *Service) Get(key string) (interface{}, error) { return redisVal, nil } +// TTL returns the seconds to expire, if the key has expiration and error if action failed. +// Read more at: https://redis.io/commands/ttl +func (r *Service) TTL(key string) (seconds int64, hasExpiration bool, ok bool) { + c := r.pool.Get() + defer c.Close() + redisVal, err := c.Do("TTL", r.Config.Prefix+key) + if err != nil { + return -2, false, false + } + seconds = redisVal.(int64) + // if -1 means the key has unlimited life time. + hasExpiration = seconds == -1 + // if -2 means key does not exist. + ok = (c.Err() != nil || seconds == -2) + return +} + // GetAll returns all redis entries using the "SCAN" command (2.8+). func (r *Service) GetAll() (interface{}, error) { c := r.pool.Get() @@ -102,6 +120,48 @@ func (r *Service) GetAll() (interface{}, error) { return redisVal, nil } +// GetKeys returns all redis keys using the "SCAN" with MATCH command. +// Read more at: https://redis.io/commands/scan#the-match-option. +func (r *Service) GetKeys(prefix string) ([]string, error) { + c := r.pool.Get() + defer c.Close() + if err := c.Err(); err != nil { + return nil, err + } + + if err := c.Send("SCAN", 0, "MATCH", r.Config.Prefix+prefix+"*", "COUNT", 9999999999); err != nil { + return nil, err + } + + if err := c.Flush(); err != nil { + return nil, err + } + + reply, err := c.Receive() + if err != nil || reply == nil { + return nil, err + } + + // it returns []interface, with two entries, the first one is "0" and the second one is a slice of the keys as []interface{uint8....}. + + if keysInterface, ok := reply.([]interface{}); ok { + if len(keysInterface) == 2 { + // take the second, it must contain the slice of keys. + if keysSliceAsBytes, ok := keysInterface[1].([]interface{}); ok { + keys := make([]string, len(keysSliceAsBytes), len(keysSliceAsBytes)) + for i, k := range keysSliceAsBytes { + keys[i] = fmt.Sprintf("%s", k) + } + + return keys, nil + } + + } + } + + return nil, nil +} + // GetBytes returns value, err by its key // you can use utils.Deserialize((.GetBytes("yourkey"),&theobject{}) //returns nil and a filled error if something wrong happens diff --git a/sessions/sessions.go b/sessions/sessions.go index e4025f84..9d5a14f6 100644 --- a/sessions/sessions.go +++ b/sessions/sessions.go @@ -107,7 +107,7 @@ func (s *Sessions) Start(ctx context.Context) *Session { sid := s.config.SessionIDGenerator() sess := s.provider.Init(sid, s.config.Expires) - sess.isNew = sess.values.Len() == 0 + sess.isNew = s.provider.db.Len(sid) == 0 s.updateCookie(ctx, sid, s.config.Expires) diff --git a/sessions/transcoding.go b/sessions/transcoding.go new file mode 100644 index 00000000..589471ea --- /dev/null +++ b/sessions/transcoding.go @@ -0,0 +1,49 @@ +package sessions + +import "encoding/json" + +type ( + // Marshaler is the common marshaler interface, used by transcoder. + Marshaler interface { + Marshal(interface{}) ([]byte, error) + } + // Unmarshaler is the common unmarshaler interface, used by transcoder. + Unmarshaler interface { + Unmarshal([]byte, interface{}) error + } + // Transcoder is the interface that transcoders should implement, it includes just the `Marshaler` and the `Unmarshaler`. + Transcoder interface { + Marshaler + Unmarshaler + } +) + +// DefaultTranscoder is the default transcoder across databases, it's the JSON by default. +// Change it if you want a different serialization/deserialization inside your session databases (when `UseDatabase` is used). +var DefaultTranscoder = defaultTranscoder{} + +type defaultTranscoder struct{} + +func (d defaultTranscoder) Marshal(value interface{}) ([]byte, error) { + if tr, ok := value.(Marshaler); ok { + return tr.Marshal(value) + } + + if jsonM, ok := value.(json.Marshaler); ok { + return jsonM.MarshalJSON() + } + + return json.Marshal(value) +} + +func (d defaultTranscoder) Unmarshal(b []byte, outPtr interface{}) error { + if tr, ok := outPtr.(Unmarshaler); ok { + return tr.Unmarshal(b, outPtr) + } + + if jsonUM, ok := outPtr.(json.Unmarshaler); ok { + return jsonUM.UnmarshalJSON(b) + } + + return json.Unmarshal(b, outPtr) +}