Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New middlewares for various client cache methods that can speed your pages even more #935

Merged
merged 5 commits into from
Mar 18, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,9 @@ after_script:
# typescript examples
- cd ./typescript/_examples
- go get ./...
- go test -v -cover ./...
- cd ../../
# make sure that the _benchmarks code is working
- cd ./_benchmarks
- go get ./...
- go test -v -cover ./...
139 changes: 139 additions & 0 deletions cache/browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package cache

import (
"strconv"
"time"

"github.com/kataras/iris/cache/client"
"github.com/kataras/iris/context"
)

// CacheControlHeaderValue is the header value of the
// "Cache-Control": "private, no-cache, max-age=0, must-revalidate, no-store, proxy-revalidate, s-maxage=0".
//
// It can be overriden.
var CacheControlHeaderValue = "private, no-cache, max-age=0, must-revalidate, no-store, proxy-revalidate, s-maxage=0"

const (
// PragmaHeaderKey is the header key of "Pragma".
PragmaHeaderKey = "Pragma"
// PragmaNoCacheHeaderValue is the header value of "Pragma": "no-cache".
PragmaNoCacheHeaderValue = "no-cache"
// ExpiresHeaderKey is the header key of "Expires".
ExpiresHeaderKey = "Expires"
// ExpiresNeverHeaderValue is the header value of "ExpiresHeaderKey": "0".
ExpiresNeverHeaderValue = "0"
)

// NoCache is a middleware which overrides the Cache-Control, Pragma and Expires headers
// in order to disable the cache during the browser's back and forward feature.
//
// A good use of this middleware is on HTML routes; to refresh the page even on "back" and "forward" browser's arrow buttons.
//
// See `cache#StaticCache` for the opposite behavior.
var NoCache = func(ctx context.Context) {
ctx.Header(context.CacheControlHeaderKey, CacheControlHeaderValue)
ctx.Header(PragmaHeaderKey, PragmaNoCacheHeaderValue)
ctx.Header(ExpiresHeaderKey, ExpiresNeverHeaderValue)
// Add the X-No-Cache header as well, for any customized case, i.e `cache#Handler` or `cache#Cache`.
client.NoCache(ctx)

ctx.Next()
}

// StaticCache middleware for caching static files by sending the "Cache-Control" and "Expires" headers to the client.
// It accepts a single input parameter, the "cacheDur", a time.Duration that it's used to calculate the expiration.
//
// If "cacheDur" <=0 then it returns the `NoCache` middleware instaed to disable the caching between browser's "back" and "forward" actions.
//
// Usage: `app.Use(cache.StaticCache(24 * time.Hour))` or `app.Use(cache.Staticcache(-1))`.
// A middleware, which is a simple Handler can be called inside another handler as well, example:
// cacheMiddleware := cache.StaticCache(...)
// func(ctx iris.Context){
// cacheMiddleware(ctx)
// [...]
// }
var StaticCache = func(cacheDur time.Duration) context.Handler {
if int64(cacheDur) <= 0 {
return NoCache
}

cacheControlHeaderValue := "public, max-age=" + strconv.Itoa(int(cacheDur.Seconds()))
return func(ctx context.Context) {
cacheUntil := time.Now().Add(cacheDur).Format(ctx.Application().ConfigurationReadOnly().GetTimeFormat())
ctx.Header(ExpiresHeaderKey, cacheUntil)
ctx.Header(context.CacheControlHeaderKey, cacheControlHeaderValue)

ctx.Next()
}
}

const ifNoneMatchHeaderKey = "If-None-Match"

// ETag is another browser & server cache request-response feature.
// It can be used side by side with the `StaticCache`, usually `StaticCache` middleware should go first.
// This should be used on routes that serves static files only.
// The key of the `ETag` is the `ctx.Request().URL.Path`, invalidation of the not modified cache method
// can be made by other request handler as well.
//
// In typical usage, when a URL is retrieved, the web server will return the resource's current
// representation along with its corresponding ETag value,
// which is placed in an HTTP response header "ETag" field:
//
// ETag: "/mypath"
//
// The client may then decide to cache the representation, along with its ETag.
// Later, if the client wants to retrieve the same URL resource again,
// it will first determine whether the local cached version of the URL has expired
// (through the Cache-Control (`StaticCache` method) and the Expire headers).
// If the URL has not expired, it will retrieve the local cached resource.
// If it determined that the URL has expired (is stale), then the client will contact the server
// and send its previously saved copy of the ETag along with the request in a "If-None-Match" field.
//
// Usage with combination of `StaticCache`:
// assets := app.Party("/assets", cache.StaticCache(24 * time.Hour), ETag)
// assets.StaticWeb("/", "./assets") or StaticEmbedded("/", "./assets") or StaticEmbeddedGzip("/", "./assets").
//
// Similar to `Cache304` but it doesn't depends on any "modified date", it uses just the ETag and If-None-Match headers.
//
// Read more at: https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching and
// https://en.wikipedia.org/wiki/HTTP_ETag
var ETag = func(ctx context.Context) {
key := ctx.Request().URL.Path
ctx.Header(context.ETagHeaderKey, key)
if match := ctx.GetHeader(ifNoneMatchHeaderKey); match == key {
ctx.WriteNotModified()
return
}
ctx.Next()
}

// Cache304 sends a `StatusNotModified` (304) whenever
// the "If-Modified-Since" request header (time) is before the
// time.Now() + expiresEvery (always compared to their UTC values).
// Use this `cache#Cache304` instead of the "github.com/kataras/iris/cache" or iris.Cache
// for better performance.
// Clients that are compatible with the http RCF (all browsers are and tools like postman)
// will handle the caching.
// The only disadvantage of using that instead of server-side caching
// is that this method will send a 304 status code instead of 200,
// So, if you use it side by side with other micro services
// you have to check for that status code as well for a valid response.
//
// Developers are free to extend this method's behavior
// by watching system directories changes manually and use of the `ctx.WriteWithExpiration`
// with a "modtime" based on the file modified date,
// can be used on Party's that contains a static handler,
// i.e `StaticWeb`, `StaticEmbedded` or even `StaticEmbeddedGzip`.
var Cache304 = func(expiresEvery time.Duration) context.Handler {
return func(ctx context.Context) {
now := time.Now()
if modified, err := ctx.CheckIfModifiedSince(now.Add(-expiresEvery)); !modified && err == nil {
ctx.WriteNotModified()
return
}

ctx.SetLastModified(now)
ctx.Next()
}
}
103 changes: 103 additions & 0 deletions cache/browser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package cache_test

import (
"strconv"
"testing"
"time"

"github.com/kataras/iris/cache"

"github.com/kataras/iris"
"github.com/kataras/iris/context"
"github.com/kataras/iris/httptest"
)

func TestNoCache(t *testing.T) {
t.Parallel()
app := iris.New()
app.Get("/", cache.NoCache, func(ctx iris.Context) {
ctx.WriteString("no_cache")
})

// tests
e := httptest.New(t, app)

r := e.GET("/").Expect().Status(httptest.StatusOK)
r.Body().Equal("no_cache")
r.Header(context.CacheControlHeaderKey).Equal(cache.CacheControlHeaderValue)
r.Header(cache.PragmaHeaderKey).Equal(cache.PragmaNoCacheHeaderValue)
r.Header(cache.ExpiresHeaderKey).Equal(cache.ExpiresNeverHeaderValue)
}

func TestStaticCache(t *testing.T) {
t.Parallel()
// test change the time format, which is not reccomended but can be done.
app := iris.New().Configure(iris.WithTimeFormat("02 Jan 2006 15:04:05 GMT"))

cacheDur := 30 * (24 * time.Hour)
var expectedTime time.Time
app.Get("/", cache.StaticCache(cacheDur), func(ctx iris.Context) {
expectedTime = time.Now()
ctx.WriteString("static_cache")
})

// tests
e := httptest.New(t, app)
r := e.GET("/").Expect().Status(httptest.StatusOK)
r.Body().Equal("static_cache")

r.Header(cache.ExpiresHeaderKey).Equal(expectedTime.Add(cacheDur).Format(app.ConfigurationReadOnly().GetTimeFormat()))
cacheControlHeaderValue := "public, max-age=" + strconv.Itoa(int(cacheDur.Seconds()))
r.Header(context.CacheControlHeaderKey).Equal(cacheControlHeaderValue)
}

func TestCache304(t *testing.T) {
t.Parallel()
app := iris.New()

expiresEvery := 4 * time.Second
app.Get("/", cache.Cache304(expiresEvery), func(ctx iris.Context) {
ctx.WriteString("send")
})
// handlers
e := httptest.New(t, app)

// when 304, content type, content length and if ETagg is there are removed from the headers.
insideCacheTimef := time.Now().Add(-expiresEvery).UTC().Format(app.ConfigurationReadOnly().GetTimeFormat())
r := e.GET("/").WithHeader(context.IfModifiedSinceHeaderKey, insideCacheTimef).Expect().Status(httptest.StatusNotModified)
r.Headers().NotContainsKey(context.ContentTypeHeaderKey).NotContainsKey(context.ContentLengthHeaderKey).NotContainsKey("ETag")
r.Body().Equal("")

// continue to the handler itself.
cacheInvalidatedTimef := time.Now().Add(expiresEvery).UTC().Format(app.ConfigurationReadOnly().GetTimeFormat()) // after ~5seconds.
r = e.GET("/").WithHeader(context.LastModifiedHeaderKey, cacheInvalidatedTimef).Expect().Status(httptest.StatusOK)
r.Body().Equal("send")
// now without header, it should continue to the handler itself as well.
r = e.GET("/").Expect().Status(httptest.StatusOK)
r.Body().Equal("send")
}
func TestETag(t *testing.T) {
t.Parallel()

app := iris.New()
n := "_"
app.Get("/", cache.ETag, func(ctx iris.Context) {
ctx.WriteString(n)
n += "_"
})

// the first and last test writes the content with status OK without cache,
// the rest tests the cache headers and status 304 and return, so body should be "".
e := httptest.New(t, app)

r := e.GET("/").Expect().Status(httptest.StatusOK)
r.Header("ETag").Equal("/") // test if header setted.
r.Body().Equal("_")

e.GET("/").WithHeader("ETag", "/").WithHeader("If-None-Match", "/").Expect().
Status(httptest.StatusNotModified).Body().Equal("") // browser is responsible, no the test engine.

r = e.GET("/").Expect().Status(httptest.StatusOK)
r.Header("ETag").Equal("/") // test if header setted.
r.Body().Equal("__")
}
6 changes: 0 additions & 6 deletions cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,3 @@ func Handler(expiration time.Duration) context.Handler {
h := Cache(expiration).ServeHTTP
return h
}

var (
// NoCache disables the cache for a particular request,
// can be used as a middleware or called manually from the handler.
NoCache = client.NoCache
)
5 changes: 3 additions & 2 deletions cache/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/kataras/iris/cache"
"github.com/kataras/iris/cache/client"
"github.com/kataras/iris/cache/client/rule"

"github.com/kataras/iris"
Expand Down Expand Up @@ -84,7 +85,7 @@ func runTest(e *httpexpect.Expect, path string, counterPtr *uint32, expectedBody
return nil
}

func TestNoCache(t *testing.T) {
func TestClientNoCache(t *testing.T) {
app := iris.New()
var n uint32

Expand All @@ -94,7 +95,7 @@ func TestNoCache(t *testing.T) {
})

app.Get("/nocache", cache.Handler(cacheDuration), func(ctx context.Context) {
cache.NoCache(ctx) // <----
client.NoCache(ctx) // <----
atomic.AddUint32(&n, 1)
ctx.Write([]byte(expectedBodyStr))
})
Expand Down
Loading