Skip to content

Commit baea475

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

File tree

5 files changed

+159
-2
lines changed

5 files changed

+159
-2
lines changed

README.md

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

52+
### Compress only for responses meeting minimum size criteria
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+
5288
### Customized Excluded Extensions
5389

5490
```go

gzip.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package gzip
22

33
import (
44
"bufio"
5+
"bytes"
56
"compress/gzip"
67
"errors"
78
"net"
@@ -25,15 +26,36 @@ func Gzip(level int, options ...Option) gin.HandlerFunc {
2526
type gzipWriter struct {
2627
gin.ResponseWriter
2728
writer *gzip.Writer
29+
// minLength is the minimum length of the response body (in bytes) to enable compression
30+
minLength int
31+
// wasMinLengthMetForCompression indicates whether the minimum length for compression has been met
32+
wasMinLengthMetForCompression bool
33+
// buffer to store response data in case compression limit not met
34+
buffer bytes.Buffer
2835
}
2936

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

3542
func (g *gzipWriter) Write(data []byte) (int, error) {
3643
g.Header().Del("Content-Length")
44+
// Check if the response body is large enough to be compressed. If so, skip this condition and proceed with the
45+
// normal write process. If not, store the data in the buffer in case more data is written later.
46+
// (At the end, if the response body is still too small, the caller should check wasMinLengthMetForCompression and
47+
// use the data stored in the buffer to write the response instead.)
48+
if !g.wasMinLengthMetForCompression && len(data) >= g.minLength {
49+
g.wasMinLengthMetForCompression = true
50+
} else if !g.wasMinLengthMetForCompression {
51+
lenWritten, err := g.buffer.Write(data)
52+
if err != nil || g.buffer.Len() < g.minLength {
53+
return lenWritten, err
54+
}
55+
g.wasMinLengthMetForCompression = true
56+
data = g.buffer.Bytes()
57+
}
58+
3759
return g.writer.Write(data)
3860
}
3961

gzip_test.go

Lines changed: 62 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"
@@ -377,6 +378,67 @@ func TestCustomShouldCompressFn(t *testing.T) {
377378
assert.Equal(t, testResponse, w.Body.String())
378379
}
379380

381+
func TestMinLengthShortResponse(t *testing.T) {
382+
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil)
383+
req.Header.Add(headerAcceptEncoding, "gzip")
384+
385+
router := gin.New()
386+
router.Use(Gzip(DefaultCompression, WithMinLength(2048)))
387+
router.GET("/", func(c *gin.Context) {
388+
c.Header("Content-Length", strconv.Itoa(len(testResponse)))
389+
c.String(200, testResponse)
390+
})
391+
392+
w := httptest.NewRecorder()
393+
router.ServeHTTP(w, req)
394+
395+
assert.Equal(t, 200, w.Code)
396+
assert.Equal(t, "", w.Header().Get(headerContentEncoding))
397+
assert.Equal(t, "19", w.Header().Get("Content-Length"))
398+
assert.Equal(t, testResponse, w.Body.String())
399+
}
400+
401+
func TestMinLengthLongResponse(t *testing.T) {
402+
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil)
403+
req.Header.Add(headerAcceptEncoding, "gzip")
404+
405+
router := gin.New()
406+
router.Use(Gzip(DefaultCompression, WithMinLength(2048)))
407+
router.GET("/", func(c *gin.Context) {
408+
c.Header("Content-Length", "2048")
409+
c.String(200, strings.Repeat("a", 2048))
410+
})
411+
412+
w := httptest.NewRecorder()
413+
router.ServeHTTP(w, req)
414+
415+
assert.Equal(t, 200, w.Code)
416+
assert.Equal(t, "gzip", w.Header().Get(headerContentEncoding))
417+
assert.NotEqual(t, "2048", w.Header().Get("Content-Length"))
418+
assert.Less(t, w.Body.Len(), 2048)
419+
}
420+
421+
func TestMinLengthMultiWriteResponse(t *testing.T) {
422+
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil)
423+
req.Header.Add(headerAcceptEncoding, "gzip")
424+
425+
router := gin.New()
426+
router.Use(Gzip(DefaultCompression, WithMinLength(2048)))
427+
router.GET("/", func(c *gin.Context) {
428+
c.Header("Content-Length", "2048")
429+
c.String(200, strings.Repeat("a", 1024))
430+
c.String(200, strings.Repeat("b", 1024))
431+
})
432+
433+
w := httptest.NewRecorder()
434+
router.ServeHTTP(w, req)
435+
436+
assert.Equal(t, 200, w.Code)
437+
assert.Equal(t, "gzip", w.Header().Get(headerContentEncoding))
438+
assert.NotEqual(t, "2048", w.Header().Get("Content-Length"))
439+
assert.Less(t, w.Body.Len(), 2048)
440+
}
441+
380442
type hijackableResponse struct {
381443
Hijacked bool
382444
header http.Header

handler.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,22 @@ 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.wasMinLengthMetForCompression {
97+
c.Writer.Header().Del(headerContentEncoding)
98+
c.Writer.Header().Del(headerVary)
99+
_, _ = gzWriter.ResponseWriter.Write(gzWriter.buffer.Bytes())
100+
gz.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
91105
gz.Reset(io.Discard)

options.go

Lines changed: 23 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,28 @@ 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+
//
128+
// Note that specifying this option does not override other options. If a path has been excluded (eg through
129+
// WithExcludedPaths), it will continue to be excluded.
130+
//
131+
// Returns:
132+
// - Option - An option that sets the MinLength field of the Options struct.
133+
//
134+
// Example:
135+
//
136+
// router.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithMinLength(2048)))
137+
func WithMinLength(minLength int) Option {
138+
return optionFunc(func(o *config) {
139+
o.minLength = minLength
140+
})
141+
}
142+
120143
// Using map for better lookup performance
121144
type ExcludedExtensions map[string]struct{}
122145

0 commit comments

Comments
 (0)