Skip to content

Commit efa14d5

Browse files
committed
Add WithMinLength option to control when responses are gzipped
1 parent 3b246bb commit efa14d5

File tree

5 files changed

+263
-12
lines changed

5 files changed

+263
-12
lines changed

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,48 @@ func main() {
4949
}
5050
```
5151

52+
### Compress only when response meets minimum byte size
53+
54+
```go
55+
package main
56+
57+
import (
58+
"log"
59+
"net/http"
60+
"strconv"
61+
"strings"
62+
63+
"github.com/gin-contrib/gzip"
64+
"github.com/gin-gonic/gin"
65+
)
66+
67+
func main() {
68+
r := gin.Default()
69+
r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithMinLength(2048)))
70+
r.GET("/ping", func(c *gin.Context) {
71+
sizeStr := c.Query("size")
72+
size, _ := strconv.Atoi(sizeStr)
73+
c.String(http.StatusOK, strings.Repeat("a", size))
74+
})
75+
76+
// Listen and Server in 0.0.0.0:8080
77+
if err := r.Run(":8080"); err != nil {
78+
log.Fatal(err)
79+
}
80+
}
81+
```
82+
Test with curl:
83+
```bash
84+
curl -i --compressed 'http://localhost:8080/ping?size=2047'
85+
curl -i --compressed 'http://localhost:8080/ping?size=2048'
86+
```
87+
88+
Notes:
89+
- If a "Content-Length" header is set, that will be used to determine whether to compress based on the given min length.
90+
- If no "Content-Length" header is set, a buffer is used to temporarily store writes until the min length is met or the request completes.
91+
- Setting a high min length will result in more buffering (2048 bytes is a recommended default for most cases)
92+
- The handler performs optimizations to avoid unnecessary operations, such as testing if `len(data)` exceeds min length before writing to the buffer, and reusing buffers between requests.
93+
5294
### Customized Excluded Extensions
5395

5496
```go

gzip.go

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package gzip
22

33
import (
44
"bufio"
5+
"bytes"
56
"compress/gzip"
67
"errors"
78
"net"
89
"net/http"
10+
"strconv"
911

1012
"github.com/gin-gonic/gin"
1113
)
@@ -25,15 +27,46 @@ func Gzip(level int, options ...Option) gin.HandlerFunc {
2527
type gzipWriter struct {
2628
gin.ResponseWriter
2729
writer *gzip.Writer
30+
// minLength is the minimum length of the response body (in bytes) to enable compression
31+
minLength int
32+
// shouldCompress indicates whether the minimum length for compression has been met
33+
shouldCompress bool
34+
// buffer to store response data in case compression limit not met
35+
buffer bytes.Buffer
2836
}
2937

3038
func (g *gzipWriter) WriteString(s string) (int, error) {
31-
g.Header().Del("Content-Length")
32-
return g.writer.Write([]byte(s))
39+
return g.Write([]byte(s))
3340
}
3441

42+
// Write writes the given data to the appropriate underlying writer.
43+
// Note that this method can be called multiple times within a single request.
3544
func (g *gzipWriter) Write(data []byte) (int, error) {
36-
g.Header().Del("Content-Length")
45+
// If a Content-Length header is set, use that to decide whether to compress the response.
46+
if g.Header().Get("Content-Length") != "" {
47+
contentLen, _ := strconv.Atoi(g.Header().Get("Content-Length")) // err intentionally ignored for invalid headers
48+
if contentLen < g.minLength {
49+
return g.ResponseWriter.Write(data)
50+
}
51+
g.shouldCompress = true
52+
g.Header().Del("Content-Length")
53+
}
54+
55+
// Check if the response body is large enough to be compressed. If so, skip this condition and proceed with the
56+
// normal write process. If not, store the data in the buffer in case more data is written later.
57+
// (At the end, if the response body is still too small, the caller should check wasMinLengthMetForCompression and
58+
// use the data stored in the buffer to write the response instead.)
59+
if !g.shouldCompress && len(data) >= g.minLength {
60+
g.shouldCompress = true
61+
} else if !g.shouldCompress {
62+
lenWritten, err := g.buffer.Write(data)
63+
if err != nil || g.buffer.Len() < g.minLength {
64+
return lenWritten, err
65+
}
66+
g.shouldCompress = true
67+
data = g.buffer.Bytes()
68+
}
69+
3770
return g.writer.Write(data)
3871
}
3972

@@ -42,12 +75,6 @@ func (g *gzipWriter) Flush() {
4275
g.ResponseWriter.Flush()
4376
}
4477

45-
// Fix: https://github.com/mholt/caddy/issues/38
46-
func (g *gzipWriter) WriteHeader(code int) {
47-
g.Header().Del("Content-Length")
48-
g.ResponseWriter.WriteHeader(code)
49-
}
50-
5178
// Ensure gzipWriter implements the http.Hijacker interface.
5279
// This will cause a compile-time error if gzipWriter does not implement all methods of the http.Hijacker interface.
5380
var _ http.Hijacker = (*gzipWriter)(nil)

gzip_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"net/http/httputil"
1414
"net/url"
1515
"strconv"
16+
"strings"
1617
"testing"
1718

1819
"github.com/gin-gonic/gin"
@@ -136,6 +137,17 @@ func TestGzipPNG(t *testing.T) {
136137
assert.Equal(t, w.Body.String(), "this is a PNG!")
137138
}
138139

140+
func TestWriteString(t *testing.T) {
141+
testC, _ := gin.CreateTestContext(httptest.NewRecorder())
142+
gz := gzipWriter{
143+
ResponseWriter: testC.Writer,
144+
writer: gzip.NewWriter(testC.Writer),
145+
}
146+
n, err := gz.WriteString("test")
147+
assert.NoError(t, err)
148+
assert.Equal(t, 4, n)
149+
}
150+
139151
func TestExcludedPathsAndExtensions(t *testing.T) {
140152
tests := []struct {
141153
path string
@@ -377,6 +389,138 @@ func TestCustomShouldCompressFn(t *testing.T) {
377389
assert.Equal(t, testResponse, w.Body.String())
378390
}
379391

392+
func TestMinLengthShortResponse(t *testing.T) {
393+
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil)
394+
req.Header.Add(headerAcceptEncoding, "gzip")
395+
396+
router := gin.New()
397+
router.Use(Gzip(DefaultCompression, WithMinLength(2048)))
398+
router.GET("/", func(c *gin.Context) {
399+
c.String(200, testResponse)
400+
})
401+
402+
w := httptest.NewRecorder()
403+
router.ServeHTTP(w, req)
404+
405+
assert.Equal(t, 200, w.Code)
406+
assert.Equal(t, "", w.Header().Get(headerContentEncoding))
407+
assert.Equal(t, "19", w.Header().Get("Content-Length"))
408+
assert.Equal(t, testResponse, w.Body.String())
409+
}
410+
411+
func TestMinLengthLongResponse(t *testing.T) {
412+
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil)
413+
req.Header.Add(headerAcceptEncoding, "gzip")
414+
415+
router := gin.New()
416+
router.Use(Gzip(DefaultCompression, WithMinLength(2048)))
417+
router.GET("/", func(c *gin.Context) {
418+
c.String(200, strings.Repeat("a", 2048))
419+
})
420+
421+
w := httptest.NewRecorder()
422+
router.ServeHTTP(w, req)
423+
424+
assert.Equal(t, 200, w.Code)
425+
assert.Equal(t, "gzip", w.Header().Get(headerContentEncoding))
426+
assert.NotEqual(t, "2048", w.Header().Get("Content-Length"))
427+
assert.Less(t, w.Body.Len(), 2048)
428+
}
429+
430+
func TestMinLengthMultiWriteResponse(t *testing.T) {
431+
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil)
432+
req.Header.Add(headerAcceptEncoding, "gzip")
433+
434+
router := gin.New()
435+
router.Use(Gzip(DefaultCompression, WithMinLength(2048)))
436+
router.GET("/", func(c *gin.Context) {
437+
c.String(200, strings.Repeat("a", 1024))
438+
c.String(200, strings.Repeat("b", 1024))
439+
})
440+
441+
w := httptest.NewRecorder()
442+
router.ServeHTTP(w, req)
443+
444+
assert.Equal(t, 200, w.Code)
445+
assert.Equal(t, "gzip", w.Header().Get(headerContentEncoding))
446+
assert.NotEqual(t, "2048", w.Header().Get("Content-Length"))
447+
assert.Less(t, w.Body.Len(), 2048)
448+
}
449+
450+
// Note this test intentionally triggers gzipping even when the actual response doesn't meet min length. This is because
451+
// we use the Content-Length header as the primary determinant of compression to avoid the cost of buffering.
452+
func TestMinLengthUsesContentLengthHeaderInsteadOfBuffering(t *testing.T) {
453+
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil)
454+
req.Header.Add(headerAcceptEncoding, "gzip")
455+
456+
router := gin.New()
457+
router.Use(Gzip(DefaultCompression, WithMinLength(2048)))
458+
router.GET("/", func(c *gin.Context) {
459+
c.Header("Content-Length", "2048")
460+
c.String(200, testResponse)
461+
})
462+
463+
w := httptest.NewRecorder()
464+
router.ServeHTTP(w, req)
465+
466+
assert.Equal(t, 200, w.Code)
467+
assert.Equal(t, "gzip", w.Header().Get(headerContentEncoding))
468+
assert.NotEmpty(t, w.Header().Get("Content-Length"))
469+
assert.NotEqual(t, "19", w.Header().Get("Content-Length"))
470+
}
471+
472+
// Note this test intentionally does not trigger gzipping even when the actual response meets min length. This is
473+
// because we use the Content-Length header as the primary determinant of compression to avoid the cost of buffering.
474+
func TestMinLengthMultiWriteResponseUsesContentLengthHeaderInsteadOfBuffering(t *testing.T) {
475+
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil)
476+
req.Header.Add(headerAcceptEncoding, "gzip")
477+
478+
router := gin.New()
479+
router.Use(Gzip(DefaultCompression, WithMinLength(1024)))
480+
router.GET("/", func(c *gin.Context) {
481+
c.Header("Content-Length", "999")
482+
c.String(200, strings.Repeat("a", 1024))
483+
c.String(200, strings.Repeat("b", 1024))
484+
})
485+
486+
w := httptest.NewRecorder()
487+
router.ServeHTTP(w, req)
488+
489+
assert.Equal(t, 200, w.Code)
490+
assert.NotEqual(t, "gzip", w.Header().Get(headerContentEncoding)) // no gzip due to Content-Length header
491+
assert.Equal(t, "2048", w.Header().Get("Content-Length"))
492+
}
493+
494+
func TestMinLengthWithInvalidContentLengthHeader(t *testing.T) {
495+
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil)
496+
req.Header.Add(headerAcceptEncoding, "gzip")
497+
498+
router := gin.New()
499+
router.Use(Gzip(DefaultCompression, WithMinLength(2048)))
500+
router.GET("/", func(c *gin.Context) {
501+
c.Header("Content-Length", "xyz")
502+
c.String(200, testResponse)
503+
})
504+
505+
w := httptest.NewRecorder()
506+
router.ServeHTTP(w, req)
507+
508+
assert.Equal(t, 200, w.Code)
509+
assert.Equal(t, "", w.Header().Get(headerContentEncoding))
510+
assert.Equal(t, "19", w.Header().Get("Content-Length"))
511+
}
512+
513+
func TestFlush(t *testing.T) {
514+
testC, _ := gin.CreateTestContext(httptest.NewRecorder())
515+
gz := gzipWriter{
516+
ResponseWriter: testC.Writer,
517+
writer: gzip.NewWriter(testC.Writer),
518+
}
519+
_, _ = gz.WriteString("test")
520+
gz.Flush()
521+
assert.True(t, gz.Written())
522+
}
523+
380524
type hijackableResponse struct {
381525
Hijacked bool
382526
header http.Header

handler.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,27 @@ func (g *gzipHandler) Handle(c *gin.Context) {
8484
if originalEtag != "" && !strings.HasPrefix(originalEtag, "W/") {
8585
c.Header("ETag", "W/"+originalEtag)
8686
}
87-
c.Writer = &gzipWriter{c.Writer, gz}
87+
gzWriter := gzipWriter{
88+
ResponseWriter: c.Writer,
89+
writer: gz,
90+
minLength: g.minLength,
91+
}
92+
c.Writer = &gzWriter
8893
defer func() {
94+
// if compression limit not met after all write commands were executed, then the response data is stored in the
95+
// internal buffer which should now be written to the response writer directly
96+
if !gzWriter.shouldCompress {
97+
c.Writer.Header().Del(headerContentEncoding)
98+
c.Writer.Header().Del(headerVary)
99+
_, _ = gzWriter.ResponseWriter.Write(gzWriter.buffer.Bytes())
100+
gzWriter.writer.Reset(io.Discard)
101+
}
102+
89103
if c.Writer.Size() < 0 {
90104
// do not write gzip footer when nothing is written to the response body
91-
gz.Reset(io.Discard)
105+
gzWriter.writer.Reset(io.Discard)
92106
}
93-
_ = gz.Close()
107+
_ = gzWriter.writer.Close()
94108
if c.Writer.Size() > -1 {
95109
c.Header("Content-Length", strconv.Itoa(c.Writer.Size()))
96110
}

options.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type config struct {
4747
decompressFn func(c *gin.Context)
4848
decompressOnly bool
4949
customShouldCompressFn func(c *gin.Context) bool
50+
minLength int
5051
}
5152

5253
// WithExcludedExtensions returns an Option that sets the ExcludedExtensions field of the Options struct.
@@ -117,6 +118,29 @@ func WithCustomShouldCompressFn(fn func(c *gin.Context) bool) Option {
117118
})
118119
}
119120

121+
// WithMinLength returns an Option that sets the minLength field of the Options struct.
122+
// Parameters:
123+
// - minLength: int - The minimum length of the response body (in bytes) to trigger gzip compression.
124+
// If the response body is smaller than this length, it will not be compressed.
125+
// This option is useful for avoiding the overhead of compression on small responses, especially since gzip
126+
// compression actually increases the size of small responses. 2048 is a recommended value for most cases.
127+
// The minLength value must be non-negative; negative values will cause undefined behavior.
128+
//
129+
// Note that specifying this option does not override other options. If a path has been excluded (eg through
130+
// WithExcludedPaths), it will continue to be excluded.
131+
//
132+
// Returns:
133+
// - Option - An option that sets the MinLength field of the Options struct.
134+
//
135+
// Example:
136+
//
137+
// router.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithMinLength(2048)))
138+
func WithMinLength(minLength int) Option {
139+
return optionFunc(func(o *config) {
140+
o.minLength = minLength
141+
})
142+
}
143+
120144
// Using map for better lookup performance
121145
type ExcludedExtensions map[string]struct{}
122146

0 commit comments

Comments
 (0)