Skip to content

Commit 262169f

Browse files
committed
Add WithMinLength option to control when responses are gzipped
1 parent 218712e commit 262169f

File tree

5 files changed

+276
-4
lines changed

5 files changed

+276
-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: 38 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
)
@@ -27,15 +29,21 @@ type gzipWriter struct {
2729
writer *gzip.Writer
2830
statusWritten bool
2931
status int
32+
// minLength is the minimum length of the response body (in bytes) to enable compression
33+
minLength int
34+
// shouldCompress indicates whether the minimum length for compression has been met
35+
shouldCompress bool
36+
// buffer to store response data in case compression limit not met
37+
buffer bytes.Buffer
3038
}
3139

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

44+
// Write writes the given data to the appropriate underlying writer.
45+
// Note that this method can be called multiple times within a single request.
3646
func (g *gzipWriter) Write(data []byte) (int, error) {
37-
g.Header().Del("Content-Length")
38-
3947
// Check status from ResponseWriter if not set via WriteHeader
4048
if !g.statusWritten {
4149
g.status = g.ResponseWriter.Status()
@@ -47,6 +55,34 @@ func (g *gzipWriter) Write(data []byte) (int, error) {
4755
return g.ResponseWriter.Write(data)
4856
}
4957

58+
// If a Content-Length header is set, use that to decide whether to compress the response.
59+
if g.Header().Get("Content-Length") != "" {
60+
// invalid header treated the same as having no Content-Length
61+
contentLen, err := strconv.Atoi(g.Header().Get("Content-Length"))
62+
if err == nil {
63+
if contentLen < g.minLength {
64+
return g.ResponseWriter.Write(data)
65+
}
66+
g.shouldCompress = true
67+
g.Header().Del("Content-Length")
68+
}
69+
}
70+
71+
// Check if the response body is large enough to be compressed. If so, skip this condition and proceed with the
72+
// normal write process. If not, store the data in the buffer in case more data is written later.
73+
// (At the end, if the response body is still too small, the caller should check shouldCompress and
74+
// use the data stored in the buffer to write the response instead.)
75+
if !g.shouldCompress && len(data) >= g.minLength {
76+
g.shouldCompress = true
77+
} else if !g.shouldCompress {
78+
lenWritten, err := g.buffer.Write(data)
79+
if err != nil || g.buffer.Len() < g.minLength {
80+
return lenWritten, err
81+
}
82+
g.shouldCompress = true
83+
data = g.buffer.Bytes()
84+
}
85+
5086
return g.writer.Write(data)
5187
}
5288

@@ -95,7 +131,6 @@ func (g *gzipWriter) WriteHeader(code int) {
95131
g.removeGzipHeaders()
96132
}
97133

98-
g.Header().Del("Content-Length")
99134
g.ResponseWriter.WriteHeader(code)
100135
}
101136

gzip_test.go

Lines changed: 155 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,149 @@ 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+
assert.NotEqual(t, "gzip", w.Header().Get(headerContentEncoding)) // no gzip since Content-Length doesn't meet min length 1024
502+
assert.Equal(t, "2048", w.Header().Get("Content-Length"))
503+
}
504+
505+
func TestMinLengthWithInvalidContentLengthHeader(t *testing.T) {
506+
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil)
507+
req.Header.Add(headerAcceptEncoding, "gzip")
508+
509+
router := gin.New()
510+
router.Use(Gzip(DefaultCompression, WithMinLength(2048)))
511+
router.GET("/", func(c *gin.Context) {
512+
c.Header("Content-Length", "xyz")
513+
c.String(200, testResponse)
514+
})
515+
516+
w := httptest.NewRecorder()
517+
router.ServeHTTP(w, req)
518+
519+
assert.Equal(t, 200, w.Code)
520+
assert.Equal(t, "", w.Header().Get(headerContentEncoding))
521+
assert.Equal(t, "19", w.Header().Get("Content-Length"))
522+
}
523+
524+
func TestFlush(t *testing.T) {
525+
testC, _ := gin.CreateTestContext(httptest.NewRecorder())
526+
gz := gzipWriter{
527+
ResponseWriter: testC.Writer,
528+
writer: gzip.NewWriter(testC.Writer),
529+
}
530+
_, _ = gz.WriteString("test")
531+
gz.Flush()
532+
assert.True(t, gz.Written())
533+
}
534+
380535
type hijackableResponse struct {
381536
Hijacked bool
382537
header http.Header

handler.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,27 @@ 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
gz.Reset(io.Discard)
97+
} else if !gw.shouldCompress {
98+
// if compression limit not met after all write commands were executed, then the response data is stored in the
99+
// internal buffer which should now be written to the response writer directly
100+
gw.Header().Del(headerContentEncoding)
101+
gw.Header().Del(headerVary)
102+
// must refer directly to embedded writer since c.Writer gets overridden
103+
_, _ = gw.ResponseWriter.Write(gw.buffer.Bytes())
104+
gz.Reset(io.Discard)
93105
} else if c.Writer.Size() < 0 {
94106
// do not write gzip footer when nothing is written to the response body
107+
// Note: This is only executed when gw.minLength == 0 (ie always compress)
95108
gz.Reset(io.Discard)
96109
}
97110
_ = 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)