Skip to content

Commit ad381b9

Browse files
committed
Add WithMinLength option to control when responses are gzipped
1 parent 18052f1 commit ad381b9

File tree

5 files changed

+282
-4
lines changed

5 files changed

+282
-4
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: 43 additions & 3 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
)
@@ -28,15 +30,21 @@ type gzipWriter struct {
2830
writer *gzip.Writer
2931
statusWritten bool
3032
status int
33+
// minLength is the minimum length of the response body (in bytes) to enable compression
34+
minLength int
35+
// shouldCompress indicates whether the minimum length for compression has been met
36+
shouldCompress bool
37+
// buffer to store response data in case minimum length for compression wasn't met
38+
buffer bytes.Buffer
3139
}
3240

3341
func (g *gzipWriter) WriteString(s string) (int, error) {
3442
return g.Write([]byte(s))
3543
}
3644

45+
// Write writes the given data to the appropriate underlying writer.
46+
// Note that this method can be called multiple times within a single request.
3747
func (g *gzipWriter) Write(data []byte) (int, error) {
38-
g.Header().Del("Content-Length")
39-
4048
// Check status from ResponseWriter if not set via WriteHeader
4149
if !g.statusWritten {
4250
g.status = g.ResponseWriter.Status()
@@ -64,6 +72,39 @@ func (g *gzipWriter) Write(data []byte) (int, error) {
6472
}
6573
}
6674

75+
// Now handle dynamic gzipping based on the client's specified minimum length
76+
// (if no min length specified, all responses get gzipped)
77+
// If a Content-Length header is set, use that to decide whether to compress so that we don't need to buffer
78+
if g.Header().Get("Content-Length") != "" {
79+
// invalid header treated the same as having no Content-Length
80+
contentLen, err := strconv.Atoi(g.Header().Get("Content-Length"))
81+
if err == nil {
82+
if contentLen < g.minLength {
83+
return g.ResponseWriter.Write(data)
84+
}
85+
g.shouldCompress = true
86+
g.Header().Del("Content-Length")
87+
}
88+
}
89+
90+
// Handle buffering here if Content-Length value couldn't tell us whether to gzip
91+
//
92+
// Check if the response body is large enough to be compressed.
93+
// - If so, skip this condition and proceed with the normal write process.
94+
// - If not, store the data in the buffer (in case more data is written in future Write calls).
95+
// (At the end, if the response body is still too small, the caller should check shouldCompress and
96+
// use the data stored in the buffer to write the response instead.)
97+
if !g.shouldCompress && len(data) >= g.minLength {
98+
g.shouldCompress = true
99+
} else if !g.shouldCompress {
100+
lenWritten, err := g.buffer.Write(data)
101+
if err != nil || g.buffer.Len() < g.minLength {
102+
return lenWritten, err
103+
}
104+
g.shouldCompress = true
105+
data = g.buffer.Bytes()
106+
}
107+
67108
return g.writer.Write(data)
68109
}
69110

@@ -111,7 +152,6 @@ func (g *gzipWriter) WriteHeader(code int) {
111152
// because some handlers (like static file server) may call WriteHeader multiple times
112153
// We'll check the status in Write() method when content is actually written
113154

114-
g.Header().Del("Content-Length")
115155
g.ResponseWriter.WriteHeader(code)
116156
}
117157

gzip_test.go

Lines changed: 156 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,150 @@ func TestCustomShouldCompressFn(t *testing.T) {
377389
assert.Equal(t, testResponse, w.Body.String())
378390
}
379391

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

handler.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,16 +84,29 @@ func (g *gzipHandler) Handle(c *gin.Context) {
8484
if originalEtag != "" && !strings.HasPrefix(originalEtag, "W/") {
8585
c.Header("ETag", "W/"+originalEtag)
8686
}
87-
gw := &gzipWriter{ResponseWriter: c.Writer, writer: gz}
87+
gw := &gzipWriter{
88+
ResponseWriter: c.Writer,
89+
writer: gz,
90+
minLength: g.minLength,
91+
}
8892
c.Writer = gw
8993
defer func() {
9094
// Only close gzip writer if it was actually used (not for error responses)
9195
if gw.status >= 400 {
9296
// Remove gzip headers for error responses when handler is complete
9397
gw.removeGzipHeaders()
9498
gz.Reset(io.Discard)
99+
} else if !gw.shouldCompress {
100+
// if compression limit not met after all write commands were executed, then the response data is stored in the
101+
// internal buffer which should now be written to the response writer directly
102+
gw.Header().Del(headerContentEncoding)
103+
gw.Header().Del(headerVary)
104+
// must refer directly to embedded writer since c.Writer gets overridden
105+
_, _ = gw.ResponseWriter.Write(gw.buffer.Bytes())
106+
gz.Reset(io.Discard)
95107
} else if c.Writer.Size() < 0 {
96108
// do not write gzip footer when nothing is written to the response body
109+
// Note: This is only executed when gw.minLength == 0 (ie always compress)
97110
gz.Reset(io.Discard)
98111
}
99112
_ = gz.Close()

options.go

Lines changed: 27 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,32 @@ 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+
if minLength < 0 {
140+
panic("minLength must be non-negative")
141+
}
142+
return optionFunc(func(o *config) {
143+
o.minLength = minLength
144+
})
145+
}
146+
120147
// Using map for better lookup performance
121148
type ExcludedExtensions map[string]struct{}
122149

0 commit comments

Comments
 (0)