From 7eb520fc6b79c066662b12e67630e2864bec8227 Mon Sep 17 00:00:00 2001 From: Gerasimos Maropoulos Date: Fri, 28 Oct 2016 11:13:12 +0300 Subject: [PATCH] [2] Continue working on #513 --- cache.go | 202 ++++++++++++++++++++++++++--------------------- cache_test.go | 53 ++++++------- configuration.go | 24 ------ context.go | 2 +- iris.go | 10 ++- 5 files changed, 144 insertions(+), 147 deletions(-) diff --git a/cache.go b/cache.go index 849539db..6f674c32 100644 --- a/cache.go +++ b/cache.go @@ -1,7 +1,6 @@ package iris import ( - "fmt" "github.com/valyala/fasthttp" "net/url" "strconv" @@ -10,18 +9,17 @@ import ( ) type ( - // CacheService is the cache service which caches the whole response body - CacheService interface { - // Start is the method which the CacheService starts the GC(check if expiration of each entry is passed , if yes then delete it from cache) - Start(time.Duration) + // CacheServiceAPI describes the cache service which caches the whole response body + CacheServiceAPI interface { // Cache accepts a route's handler which will cache its response and a time.Duration(int64) which is the expiration duration Cache(HandlerFunc, time.Duration) HandlerFunc // ServeRemoteCache creates & returns a new handler which saves cache by POST method and serves a cache entry by GET method to clients // usually set it with iris.Any, // but developer is able to set different paths for save or get cache entries: using the iris.Post/.Get(...,iris.ServeRemote()) - // - // IT IS NOT READY FOR PRODUCTION YET, READ THE HISTORY.md for the available working cache methods - ServeRemoteCache() HandlerFunc + // CacheRemote IS not ready for production yet and that's why it is not in docs or history yet. + // propably this method will go to another package which will be ready at the next 24 hours, + // because it can work both on iris and raw net/http, lets no limit it:) + ServeRemoteCache(time.Duration) HandlerFunc // Invalidate accepts a cache key (which can be retrieved by 'GetCacheKey') and remove its cache response body InvalidateCache(string) } @@ -30,52 +28,57 @@ type ( cache map[string]*cacheEntry mu sync.RWMutex // keep track of the minimum cache duration of all cache entries, this will be used when gcDuration inside .start() is <=time.Second - lowerExpiration time.Duration + gcDuration time.Duration } cacheEntry struct { statusCode int contentType string - value []byte - expires time.Time + body []byte + // we could have a new Timer foreach cache entry in order to be persise on the expiration but this will cost us a lot of performance, + // (the ticker should be stopped if delete or key ovveride and so on...) + // but I chosen to just have a generic timer with its tick on the lowest 'expires' of all cache entries that cache keeps + expires time.Time } ) -var _ CacheService = &cacheService{} +func (e *cacheEntry) serve(ctx *Context) { + ctx.SetContentType(e.contentType) + ctx.SetStatusCode(e.statusCode) + ctx.RequestCtx.Write(e.body) +} + +var _ CacheServiceAPI = &cacheService{} func newCacheService() *cacheService { cs := &cacheService{ - cache: make(map[string]*cacheEntry), - mu: sync.RWMutex{}, - lowerExpiration: time.Second, + cache: make(map[string]*cacheEntry), + mu: sync.RWMutex{}, + gcDuration: -1, // will set as the lowest of the cache entries, if not set then the cache doesn't starts its garbage collector } return cs } -// Start is not called via newCacheService because -// if gcDuration is <=time.Second -// then start should check and set the gcDuration from the TOTAL CACHE ENTRIES lowest expiration duration -func (cs *cacheService) Start(gcDuration time.Duration) { - - if gcDuration <= minimumAllowedCacheDuration { - gcDuration = cs.lowerExpiration - } - - // start the timer to check for expirated cache entries - tick := time.Tick(gcDuration) - go func() { - for range tick { - cs.mu.Lock() - now := time.Now() - for k, v := range cs.cache { - if now.Before(v.expires) { - delete(cs.cache, k) +// start called last (after the lowest cache gc duration has been setted by the Cache funcs) +func (cs *cacheService) start() { + if cs.gcDuration > 0 { + // start the timer to check for expirated cache entries + tick := time.Tick(cs.gcDuration) + go func() { + for range tick { + cs.mu.Lock() + now := time.Now() + for k, v := range cs.cache { + if now.After(v.expires) { + println("remove cache") + delete(cs.cache, k) + } } + cs.mu.Unlock() } - cs.mu.Unlock() - } - }() + }() + } } @@ -89,28 +92,37 @@ func (cs *cacheService) get(key string) *cacheEntry { return nil } -// we don't set it to zero value, just 2050 year is enough xD -var expiresNever = time.Date(2050, time.January, 10, 23, 0, 0, 0, time.UTC) -var minimumAllowedCacheDuration = time.Second +var minimumAllowedCacheDuration = 2 * time.Second -func (cs *cacheService) set(key string, statusCode int, contentType string, value []byte, expiration time.Duration) { - if statusCode == 0 { +func validateCacheDuration(expiration time.Duration) time.Duration { + if expiration <= minimumAllowedCacheDuration { + expiration = minimumAllowedCacheDuration * 2 + } + return expiration +} + +func validateStatusCode(statusCode int) int { + if statusCode <= 0 { statusCode = StatusOK } - if contentType == "" { - contentType = contentText + return statusCode +} + +func validateContentType(cType string) string { + if cType == "" { + cType = contentText } + return cType +} - entry := &cacheEntry{contentType: contentType, statusCode: statusCode, value: value} - - if expiration <= minimumAllowedCacheDuration { - // Cache function tries to set the expiration(seconds) from header "cache-control" if expiration <=minimumAllowedCacheDuration - // but if cache-control is missing then set it to 5 minutes - expiration = 5 * time.Minute +func (cs *cacheService) set(key string, statusCode int, cType string, body []byte, expiration time.Duration) { + entry := &cacheEntry{ + statusCode: validateStatusCode(statusCode), + contentType: validateContentType(cType), + expires: time.Now().Add(validateCacheDuration(expiration)), + body: body, } - entry.expires = time.Now().Add(expiration) - cs.mu.Lock() cs.cache[key] = entry cs.mu.Unlock() @@ -136,7 +148,7 @@ func GetCacheKey(ctx *Context) string { // // Example: https://github.com/iris-contrib/examples/tree/master/cache_body func InvalidateCache(key string) { - Default.CacheService.InvalidateCache(key) + Default.InvalidateCache(key) } // InvalidateCache clears the cache body for a specific key(request uri, can be retrieved by GetCacheKey(ctx)) @@ -162,7 +174,15 @@ func (cs *cacheService) InvalidateCache(key string) { // // Example: https://github.com/iris-contrib/examples/tree/master/cache_body func Cache(bodyHandler HandlerFunc, expiration time.Duration) HandlerFunc { - return Default.CacheService.Cache(bodyHandler, expiration) + return Default.Cache(bodyHandler, expiration) +} + +func getResponseContentType(ctx *Context) string { + return validateContentType(string(ctx.Response.Header.ContentType())) +} + +func getResponseStatusCode(ctx *Context) int { + return validateStatusCode(ctx.Response.StatusCode()) } // Cache is just a wrapper for a route's handler which you want to enable body caching @@ -177,34 +197,37 @@ func Cache(bodyHandler HandlerFunc, expiration time.Duration) HandlerFunc { // // Example: https://github.com/iris-contrib/examples/tree/master/cache_body func (cs *cacheService) Cache(bodyHandler HandlerFunc, expiration time.Duration) HandlerFunc { + expiration = validateCacheDuration(expiration) - // the first time the lowerExpiration should be > time.Second, so: - if cs.lowerExpiration == time.Second { - cs.lowerExpiration = expiration - } else if expiration > time.Second && expiration < cs.lowerExpiration { - cs.lowerExpiration = expiration + if cs.gcDuration == -1 { + // if gc duration is not setted yet or this is the only one Cache which happens to have bigger expiration than the minimumAllowedCacheDuration + // then set that as the gcDuration + cs.gcDuration = expiration // the first time the lowerExpiration should be > minimumAllowedCacheDuration so: + } else if expiration < cs.gcDuration { // find the lower + // if this expiration is lower than the already setted, set the gcDuration to this + cs.gcDuration = expiration } h := func(ctx *Context) { key := GetCacheKey(ctx) if v := cs.get(key); v != nil { - ctx.SetContentType(v.contentType) - ctx.SetStatusCode(v.statusCode) - ctx.RequestCtx.Write(v.value) + v.serve(ctx) return } - // if not found then serve this: + // if not found then serve the handler and collect its results after bodyHandler.Serve(ctx) + if expiration <= minimumAllowedCacheDuration { // try to set the expiraion from header expiration = time.Duration(ctx.MaxAge()) * time.Second } - cType := string(ctx.Response.Header.Peek(contentType)) - statusCode := ctx.RequestCtx.Response.StatusCode() + cType := getResponseContentType(ctx) + statusCode := getResponseStatusCode(ctx) + body := ctx.Response.Body() // and set the cache value as its response body in a goroutine, because we want to exit from the route's handler as soon as possible - go cs.set(key, statusCode, cType, ctx.Response.Body(), expiration) + go cs.set(key, statusCode, cType, body, expiration) } return h @@ -217,6 +240,13 @@ func GetRemoteCacheKey(ctx *Context) string { return url.QueryEscape(ctx.Request.URI().String()) } +const ( + queryCacheKey = "cache_key" + queryCacheDuration = "cache_duration" + queryCacheStatusCode = "cache_status_code" + queryCacheContentType = "cache_content_type" +) + // RemoteCache accepts the remote server address and path of the external cache service, the body handler and optional an expiration // the last 2 receivers works like .Cache(...) function // @@ -227,7 +257,7 @@ func GetRemoteCacheKey(ctx *Context) string { func RemoteCache(cacheServerAddr string, bodyHandler HandlerFunc, expiration time.Duration) HandlerFunc { client := fasthttp.Client{} // buf := utils.NewBufferPool(10) - cacheDurationStr := fmt.Sprintf("%f", expiration.Seconds()) + cacheDurationStr := strconv.Itoa(int(expiration.Seconds())) h := func(ctx *Context) { req := fasthttp.AcquireRequest() req.SetRequestURI(cacheServerAddr) @@ -247,12 +277,10 @@ func RemoteCache(cacheServerAddr string, bodyHandler HandlerFunc, expiration tim req.URI().QueryArgs().Add("cache_status_code", statusCode) cType := string(ctx.Response.Header.Peek(contentType)) req.URI().QueryArgs().Add("cache_content_type", cType) - postArgs := fasthttp.AcquireArgs() - postArgs.SetBytesV("cache_body", ctx.Response.Body()) + req.SetBody(ctx.Response.Body()) go func() { client.DoTimeout(req, res, time.Duration(5)*time.Second) - fasthttp.ReleaseArgs(postArgs) fasthttp.ReleaseRequest(req) fasthttp.ReleaseResponse(res) }() @@ -285,11 +313,9 @@ func RemoteCache(cacheServerAddr string, bodyHandler HandlerFunc, expiration tim // // Note that it depends on a station instance's cache service. // Do not try to call it from default' station if you use the form of app := iris.New(), -// use the app.ServeRemoteCache instead of iris.Cache -// -// IT IS NOT READY FOR PRODUCTION YET, READ THE HISTORY.md for the available working cache methods -func ServeRemoteCache() HandlerFunc { - return Default.CacheService.ServeRemoteCache() +// use the app.ServeRemoteCache instead of iris.ServeRemoteCache +func ServeRemoteCache(gcDuration time.Duration) HandlerFunc { + return Default.ServeRemoteCache(gcDuration) } // ServeRemoteCache usage: iris.Any("/cacheservice", iris.ServeRemoteCache()) @@ -303,10 +329,10 @@ func ServeRemoteCache() HandlerFunc { // // Note that it depends on a station instance's cache service. // Do not try to call it from default' station if you use the form of app := iris.New(), -// use the app.ServeRemoteCache instead of iris.Cache -// -// IT IS NOT READY FOR PRODUCTION YET, READ THE HISTORY.md for the available working cache methods -func (cs *cacheService) ServeRemoteCache() HandlerFunc { +// use the app.ServeRemoteCache instead of iris.ServeRemoteCache +func (cs *cacheService) ServeRemoteCache(gcDuration time.Duration) HandlerFunc { + cs.gcDuration = validateCacheDuration(gcDuration) + h := func(ctx *Context) { key := ctx.URLParam("cache_key") if key == "" { @@ -316,22 +342,18 @@ func (cs *cacheService) ServeRemoteCache() HandlerFunc { if ctx.IsGet() { if v := cs.get(key); v != nil { - ctx.SetStatusCode(v.statusCode) - ctx.SetContentType(v.contentType) - ctx.RequestCtx.Write(v.value) + v.serve(ctx) return } } else if ctx.IsPost() { // get the cache expiration via url param expirationSeconds, err := ctx.URLParamInt64("cache_duration") - // get the body from the post arguments or requested body - body := ctx.PostArgs().Peek("cache_body") + // get the body from the requested body + body := ctx.Request.Body() if len(body) == 0 { - body = ctx.Request.Body() - if len(body) == 0 { - ctx.SetStatusCode(StatusBadRequest) - return - } + ctx.SetStatusCode(StatusBadRequest) + return + } // get the expiration from the "cache-control's maxage" if no url param is setted if expirationSeconds <= 0 || err != nil { @@ -340,7 +362,7 @@ func (cs *cacheService) ServeRemoteCache() HandlerFunc { // if not setted then try to get it via if expirationSeconds <= 0 { - expirationSeconds = 5 * 60 // 5 minutes + expirationSeconds = int64(minimumAllowedCacheDuration.Seconds()) } cacheDuration := time.Duration(expirationSeconds) * time.Second @@ -366,5 +388,5 @@ func (cs *cacheService) ServeRemoteCache() HandlerFunc { ctx.SetStatusCode(StatusBadRequest) } - return cs.Cache(h, -1) + return h } diff --git a/cache_test.go b/cache_test.go index 025b881d..350503db 100644 --- a/cache_test.go +++ b/cache_test.go @@ -57,7 +57,7 @@ All features of Sundown are supported, including: // EXAMPLE: https://github.com/iris-contrib/examples/tree/master/cache_body func TestCacheCanRender(t *testing.T) { iris.ResetDefault() - iris.Config.CacheGCDuration = time.Duration(10) * time.Second + iris.Config.IsDevelopment = true defer iris.Close() var i = 1 @@ -83,48 +83,45 @@ func TestCacheCanRender(t *testing.T) { time.Sleep(5 * time.Second) // let's sleep for a while in order to be saved in cache(running in goroutine) e.GET("/").Expect().Status(iris.StatusOK).Body().Equal(expectedBody) // the 1 minute didnt' passed so it should work - // travis... and time sleep not a good idea for testing, we will see what we can do other day, the cache is tested on examples too* - /*e.GET("/").Expect().Status(iris.StatusOK).Body().Equal(expectedBody) // the cache still son the corrrect body so no StatusNoContent fires - time.Sleep(time.Duration(5) * time.Second) // 4 depends on the CacheGCDuration not the expiration - - // the cache should be cleared and now i = 2 then it should run the iris.StatusNoContent with empty body ( we don't use the EmitError) - e.GET("/").Expect().Status(iris.StatusNoContent).Body().Empty() - time.Sleep(time.Duration(5) * time.Second) - - e.GET("/").Expect().Status(iris.StatusOK).Body().Equal(expectedBody) - e.GET("/").Expect().Status(iris.StatusOK).Body().Equal(expectedBody)*/ } +// CacheRemote IS not ready for production yet // func TestCacheRemote(t *testing.T) { // iris.ResetDefault() // // setup the remote cache service listening on localhost:8888/cache -// remoteService := iris.New(iris.OptionCacheGCDuration(1*time.Minute), iris.OptionDisableBanner(true), iris.OptionIsDevelopment(true)) -// remoteService.Any("/cache", remoteService.ServeRemoteCache()) +// remoteService := iris.New(iris.OptionDisableBanner(true)) +// remoteService.Any("/cache", remoteService.ServeRemoteCache(5*time.Second)) // clear the gc every 5 seconds // defer remoteService.Close() -// go remoteService.Listen(":8888") +// go remoteService.Listen("localhost:8888") // <-remoteService.Available // -// app := iris.New(iris.OptionIsDevelopment(true)) +// app := iris.New() // -// expectedBody := iris.SerializeToString("text/markdown", testMarkdownContents) -// -// i := 1 +// n := 1 // bodyHandler := func(ctx *iris.Context) { -// if i%2 == 0 { // only for testing -// ctx.SetStatusCode(iris.StatusNoContent) -// i++ -// return -// } -// i++ +// n++ // ctx.Markdown(iris.StatusOK, testMarkdownContents) // } // -// app.Get("/", iris.RemoteCache("http://127.0.0.1:8888/cache", bodyHandler, time.Duration(15)*time.Second)) +// app.Get("/", iris.RemoteCache("http://localhost:8888/cache", bodyHandler, 10*time.Second)) // -// e := httptest.New(app, t, httptest.Debug(true)) +// e := httptest.New(app, t, httptest.Debug(false)) +// +// expectedBody := app.SerializeToString("text/markdown", testMarkdownContents) // // e.GET("/").Expect().Status(iris.StatusOK).Body().Equal(expectedBody) +// time.Sleep(3 * time.Second) // let's wait a while because saving is going on a goroutine (in some ms, but travis is slow so 2 seconds wait) +// // we are in cache, so the 'n' should be 1 +// e.GET("/").Expect().Status(iris.StatusOK).Body().Equal(expectedBody) +// if n > 1 { +// // n should be 1 because it doesn't changed after the first call +// t.Fatalf("Expected n = %d but got %d. Cache has problems!!", 1, n) +// } +// +// // let's wait 5 more seconds, the cache should be cleared now the n should be 2 // time.Sleep(5 * time.Second) -// e.GET("/").Expect().Status(iris.StatusOK).Body().Equal(expectedBody) -// +// e.GET("/").Expect().Status(iris.StatusNoContent).Body().Empty() +// if n != 2 { +// t.Fatalf("Expected n = %d but got %d. Cache has problems!!", 2, n) +// } // } diff --git a/configuration.go b/configuration.go index 50f718bb..dfa4a74c 100644 --- a/configuration.go +++ b/configuration.go @@ -204,15 +204,6 @@ type Configuration struct { // Sessions contains the configs for sessions Sessions SessionsConfiguration - // CacheGCDuration the cache gc duration, - // when this duration is passed then the cache is checking for each of the cache entries' expiration field - // this clears only the Cached handlers, so if you don't want cache then don't pass your handler arround the Cache wrapper - // it's like the session's GcDuration field - // - // Is if your app is big and not very changable (like a blog) set this duration big , like 5 hours - // - // Defaults to Auto, Auto means that is setted by the lowest expiration of all cach entries - CacheGCDuration time.Duration // Websocket contains the configs for Websocket's server integration Websocket WebsocketConfiguration @@ -434,20 +425,6 @@ var ( } } - // OptionCacheGCDuration ses the cache gc duration, - // when this duration is passed then the cache is checking for each of the cache entries' expiration field - // this clears only the Cached handlers, so if you don't want cache then don't pass your handler arround the Cache wrapper - // it's like the session's GcDuration field - // - // Is if your app is big and not very changable (like a blog) set this duration big , like 5 hours - // - // Defaults to Auto, Auto means that is setted by the lowest expiration of all cach entries - OptionCacheGCDuration = func(val time.Duration) OptionSet { - return func(c *Configuration) { - c.CacheGCDuration = val - } - } - // OptionIsDevelopment iris will act like a developer, for example // If true then re-builds the templates on each request // Default is false @@ -565,7 +542,6 @@ func DefaultConfiguration() Configuration { Charset: DefaultCharset, Gzip: false, Sessions: DefaultSessionsConfiguration(), - CacheGCDuration: minimumAllowedCacheDuration, Websocket: DefaultWebsocketConfiguration(), Other: options.Options{}, } diff --git a/context.go b/context.go index 221e862d..9db8a55c 100644 --- a/context.go +++ b/context.go @@ -1133,7 +1133,7 @@ func (ctx *Context) MaxAge() int64 { // InvalidateCache clears the cache manually for this request uri context's handler's route func (ctx *Context) InvalidateCache() { - ctx.framework.CacheService.InvalidateCache(GetCacheKey(ctx)) + ctx.framework.cacheService.InvalidateCache(GetCacheKey(ctx)) } // Log logs to the iris defined logger diff --git a/iris.go b/iris.go index b60bd1da..ae70da78 100644 --- a/iris.go +++ b/iris.go @@ -142,7 +142,7 @@ type ( // FrameworkAPI contains the main Iris Public API FrameworkAPI interface { MuxAPI - CacheService + CacheServiceAPI Set(...OptionSetter) Must(error) Build() @@ -176,7 +176,7 @@ type ( // Implements the FrameworkAPI Framework struct { *muxAPI - CacheService + *cacheService // HTTP Server runtime fields is the iris' defined main server, developer can use unlimited number of servers // note: they're available after .Build, and .Serve/Listen/ListenTLS/ListenLETSENCRYPT/ListenUNIX ln net.Listener @@ -233,7 +233,7 @@ func New(setters ...OptionSetter) *Framework { "urlpath": s.Path, }) // set the cache service - s.CacheService = newCacheService() + s.cacheService = newCacheService() } // websocket & sessions @@ -354,7 +354,9 @@ func (s *Framework) Build() { } // set the cache gc duration and start service - s.CacheService.Start(s.Config.CacheGCDuration) + if s.cacheService.gcDuration > 0 { + s.cacheService.start() + } if s.Config.Websocket.Endpoint != "" { // register the websocket server and listen to websocket connections when/if $instance.Websocket.OnConnection called by the dev