Skip to content

Commit 282a77f

Browse files
committed
feat: add proxy for gin
1 parent bd02e1e commit 282a77f

File tree

5 files changed

+495
-0
lines changed

5 files changed

+495
-0
lines changed

pkg/gin/proxy/README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
## Gin Proxy
2+
3+
A production-grade reverse proxy library implemented in Go. It not only provides high performance and multiple load balancing strategies, but also supports **dynamic route management**, allowing you to add or remove backend servers at runtime via API — without restarting the service.
4+
5+
### Core Features
6+
7+
* **Dynamic Service Discovery**: Add or remove backend nodes in real-time through HTTP APIs.
8+
* **High Performance Core**: Built on `net/http/httputil` with deeply optimized connection pooling for effortless high-concurrency handling.
9+
* **Rich Load Balancing Strategies**: Includes Round Robin, The Least Connections, and IP Hash.
10+
* **Active Health Checks**: Automatically detects and isolates unhealthy nodes, and brings them back online once they recover.
11+
* **Multi-route Support**: Distribute traffic to different backend groups based on path prefixes.
12+
13+
### Example of Usage
14+
15+
```go
16+
package main
17+
18+
import (
19+
"fmt"
20+
"github.com/go-dev-frame/sponge/pkg/gin/proxy"
21+
"github.com/gin-gonic/gin"
22+
)
23+
24+
func main() {
25+
r := gin.Default()
26+
27+
p := proxy.New(r) // default configuration, managerPrefixPath = "/endpoints"
28+
pass1(p)
29+
pass2(p)
30+
31+
// Other normal routes
32+
r.GET("/ping", func(c *gin.Context) {
33+
c.JSON(200, gin.H{"message": "pong"})
34+
})
35+
36+
fmt.Println("Gin server with dynamic proxy started on http://localhost:8080")
37+
38+
r.Run(":8080")
39+
}
40+
41+
func pass1(p *proxy.Proxy) {
42+
prefixPath := "/proxy/"
43+
initialTargets := []string{"http://localhost:8081", "http://localhost:8082"}
44+
err := p.Pass(prefixPath, initialTargets) // default configuration, balancer = RoundRobin, healthCheckInterval = 5s, healthCheckTimeout = 3s
45+
if err != nil {
46+
panic(err)
47+
}
48+
}
49+
50+
func pass2(p *proxy.Proxy) {
51+
prefixPath := "/personal/"
52+
initialTargets := []string{"http://localhost:8083", "http://localhost:8084"}
53+
err := p.Pass(prefixPath, initialTargets)
54+
if err != nil {
55+
panic(err)
56+
}
57+
}
58+
```
59+
60+
<br>
61+
62+
### Advanced Settings
63+
64+
1. Configure the management endpoints' route prefix, middleware, and logger through function parameters of `proxy.New`.
65+
```go
66+
p := proxy.New(r, proxy.Config{
67+
proxy.WithManagerEndpoints("/admin", Middlewares...),
68+
proxy.WithLogger(zap.NewExample()),
69+
})
70+
```
71+
72+
2. Configure the load balancing type, health check interval, and middleware for the endpoints through function parameters of `p.Pass`.
73+
```go
74+
err := p.Pass("/proxy/", []string{"http://localhost:8081", "http://localhost:8082"}, proxy.Config{
75+
proxy.WithPassBalancer(proxy.BalancerIPHash),
76+
proxy.WithPassHealthCheck(time.Second * 5, time.Second * 3),
77+
proxy.WithPassMiddlewares(Middlewares...),
78+
})
79+
```

pkg/gin/proxy/proxy.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package proxy
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/gin-gonic/gin"
7+
8+
"github.com/go-dev-frame/sponge/pkg/proxykit"
9+
)
10+
11+
// Proxy is a proxy server.
12+
type Proxy struct {
13+
r *gin.Engine
14+
manager *proxykit.RouteManager
15+
}
16+
17+
// New creates a new Proxy instance.
18+
func New(r *gin.Engine, opts ...Option) *Proxy {
19+
o := defaultOptions()
20+
o.apply(opts...)
21+
22+
if o.zapLogger != nil {
23+
proxykit.SetLogger(o.zapLogger)
24+
}
25+
26+
manager := proxykit.NewRouteManager()
27+
28+
// setup manager endpoints routes
29+
managerRelativePath := o.managerPrefixPath
30+
var managerGroup *gin.RouterGroup
31+
if len(o.managerMiddlewares) > 0 {
32+
managerGroup = r.Group(managerRelativePath, o.managerMiddlewares...)
33+
} else {
34+
managerGroup = r.Group(managerRelativePath)
35+
}
36+
{
37+
managerGroup.POST("/add", gin.WrapF(manager.HandleAddBackends))
38+
managerGroup.POST("/remove", gin.WrapF(manager.HandleRemoveBackends))
39+
managerGroup.GET("/list", gin.WrapF(manager.HandleListBackends))
40+
managerGroup.GET("", gin.WrapF(manager.HandleGetBackend))
41+
}
42+
43+
return &Proxy{
44+
r: r,
45+
manager: manager,
46+
}
47+
}
48+
49+
// Pass registers proxy endpoints to gin engine.
50+
func (p *Proxy) Pass(prefixPath string, endpoints []string, opts ...PassOption) error {
51+
o := defaultPassOptions()
52+
o.apply(opts...)
53+
54+
backends, err := proxykit.ParseBackends(prefixPath, endpoints)
55+
if err != nil {
56+
return fmt.Errorf("parse backends error: %v", err)
57+
}
58+
proxykit.StartHealthChecks(backends, proxykit.HealthCheckConfig{
59+
Interval: o.healthCheckInterval,
60+
Timeout: o.healthCheckTimeout,
61+
})
62+
63+
var balancer proxykit.Balancer
64+
switch o.balancerType {
65+
case BalancerRoundRobin:
66+
balancer = proxykit.NewRoundRobin(backends)
67+
case BalancerLeastConn:
68+
balancer = proxykit.NewLeastConnections(backends)
69+
case BalancerIPHash:
70+
balancer = proxykit.NewIPHash(backends)
71+
default:
72+
return fmt.Errorf("unsupported balancer type: %s", o.balancerType)
73+
}
74+
75+
apiRoute, err := p.manager.AddRoute(prefixPath, balancer)
76+
if err != nil {
77+
return fmt.Errorf("could not add initial route: %v", err)
78+
}
79+
80+
// setup proxy endpoints routes
81+
proxyRelativePath := proxykit.AnyRelativePath(prefixPath) // /prefixPath/*path
82+
proxyHandlerFuncs := append(o.passMiddlewares, gin.WrapH(apiRoute.Proxy))
83+
p.r.Any(proxyRelativePath, proxyHandlerFuncs...)
84+
85+
return nil
86+
}

pkg/gin/proxy/proxy_option.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package proxy
2+
3+
import (
4+
"strings"
5+
"time"
6+
7+
"github.com/gin-gonic/gin"
8+
"go.uber.org/zap"
9+
)
10+
11+
// Option set options.
12+
type Option func(*options)
13+
14+
type options struct {
15+
managerPrefixPath string // default "/endpoints"
16+
managerMiddlewares []gin.HandlerFunc
17+
zapLogger *zap.Logger
18+
}
19+
20+
func (o *options) apply(opts ...Option) {
21+
for _, opt := range opts {
22+
opt(o)
23+
}
24+
}
25+
26+
func defaultOptions() *options {
27+
return &options{
28+
managerPrefixPath: "/endpoints",
29+
}
30+
}
31+
32+
// WithManagerEndpoints sets manager prefix path and middlewares, managerPrefixPath default "/endpoints".
33+
func WithManagerEndpoints(managerPrefixPath string, middlewares ...gin.HandlerFunc) Option {
34+
return func(o *options) {
35+
if managerPrefixPath != "" {
36+
if !strings.HasPrefix(managerPrefixPath, "/") {
37+
managerPrefixPath = "/" + managerPrefixPath
38+
}
39+
managerPrefixPath = strings.TrimSuffix(managerPrefixPath, "/")
40+
o.managerPrefixPath = managerPrefixPath
41+
}
42+
o.managerMiddlewares = middlewares
43+
}
44+
}
45+
46+
// WithLogger sets logger.
47+
func WithLogger(logger *zap.Logger) Option {
48+
return func(o *options) {
49+
o.zapLogger = logger
50+
}
51+
}
52+
53+
// -------------------------------------------------------------------------------------------
54+
55+
var (
56+
BalancerRoundRobin = "round_robin"
57+
BalancerLeastConn = "least_conn"
58+
BalancerIPHash = "ip_hash"
59+
)
60+
61+
// PassOption set passOptions.
62+
type PassOption func(*passOptions)
63+
64+
type passOptions struct {
65+
healthCheckInterval time.Duration // default 5s
66+
healthCheckTimeout time.Duration // default 3s
67+
balancerType string // supported values: "round_robin", "least_conn", "ip_hash", default "round_robin"
68+
passMiddlewares []gin.HandlerFunc
69+
}
70+
71+
func (o *passOptions) apply(opts ...PassOption) {
72+
for _, opt := range opts {
73+
opt(o)
74+
}
75+
}
76+
77+
func defaultPassOptions() *passOptions {
78+
return &passOptions{
79+
healthCheckInterval: 5 * time.Second,
80+
healthCheckTimeout: 3 * time.Second,
81+
balancerType: BalancerRoundRobin,
82+
}
83+
}
84+
85+
// WithPassBalancer sets balancer type.
86+
func WithPassBalancer(balancerType string) PassOption {
87+
return func(o *passOptions) {
88+
o.balancerType = balancerType
89+
}
90+
}
91+
92+
// WithPassHealthCheck sets health check interval and timeout.
93+
func WithPassHealthCheck(interval time.Duration, timeout time.Duration) PassOption {
94+
return func(o *passOptions) {
95+
if interval >= time.Second {
96+
o.healthCheckInterval = interval
97+
}
98+
if timeout >= time.Millisecond*100 {
99+
o.healthCheckTimeout = timeout
100+
}
101+
}
102+
}
103+
104+
// WithPassMiddlewares sets proxy middlewares.
105+
func WithPassMiddlewares(middlewares ...gin.HandlerFunc) PassOption {
106+
return func(o *passOptions) {
107+
o.passMiddlewares = middlewares
108+
}
109+
}

pkg/gin/proxy/proxy_option_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package proxy
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/gin-gonic/gin"
8+
"go.uber.org/zap"
9+
)
10+
11+
func TestDefaultOptions(t *testing.T) {
12+
opts := defaultOptions()
13+
if opts.managerPrefixPath != "/endpoints" {
14+
t.Errorf("expected default prefix /endpoints, got %s", opts.managerPrefixPath)
15+
}
16+
if opts.managerMiddlewares != nil {
17+
t.Errorf("expected nil manager middlewares by default")
18+
}
19+
}
20+
21+
func TestWithManagerEndpoints(t *testing.T) {
22+
mw := func(c *gin.Context) {}
23+
opts := defaultOptions()
24+
opts.apply(WithManagerEndpoints("api", mw))
25+
26+
if opts.managerPrefixPath != "/api" {
27+
t.Errorf("expected /api, got %s", opts.managerPrefixPath)
28+
}
29+
if len(opts.managerMiddlewares) != 1 {
30+
t.Errorf("expected one middleware")
31+
}
32+
}
33+
34+
func TestWithManagerEndpointsWithSlash(t *testing.T) {
35+
opts := defaultOptions()
36+
opts.apply(WithManagerEndpoints("/api/"))
37+
38+
if opts.managerPrefixPath != "/api" {
39+
t.Errorf("expected /api (trimmed), got %s", opts.managerPrefixPath)
40+
}
41+
}
42+
43+
func TestWithManagerEndpointsEmpty(t *testing.T) {
44+
opts := defaultOptions()
45+
opts.apply(WithManagerEndpoints(""))
46+
47+
// should keep default
48+
if opts.managerPrefixPath != "/endpoints" {
49+
t.Errorf("expected default /endpoints, got %s", opts.managerPrefixPath)
50+
}
51+
}
52+
53+
func TestWithLogger(t *testing.T) {
54+
logger, _ := zap.NewDevelopment()
55+
opts := defaultOptions()
56+
opts.apply(WithLogger(logger))
57+
58+
if opts.zapLogger == nil {
59+
t.Errorf("expected logger to be set")
60+
}
61+
}
62+
63+
func TestDefaultPassOptions(t *testing.T) {
64+
opts := defaultPassOptions()
65+
if opts.healthCheckInterval != 5*time.Second {
66+
t.Errorf("expected default 5s, got %v", opts.healthCheckInterval)
67+
}
68+
if opts.healthCheckTimeout != 3*time.Second {
69+
t.Errorf("expected default 3s, got %v", opts.healthCheckTimeout)
70+
}
71+
if opts.balancerType != "round_robin" {
72+
t.Errorf("expected default round_robin, got %s", opts.balancerType)
73+
}
74+
}
75+
76+
func TestWithPassBalancer(t *testing.T) {
77+
opts := defaultPassOptions()
78+
opts.apply(WithPassBalancer("least_conn"))
79+
80+
if opts.balancerType != "least_conn" {
81+
t.Errorf("expected least_conn, got %s", opts.balancerType)
82+
}
83+
}
84+
85+
func TestWithPassHealthCheckValid(t *testing.T) {
86+
opts := defaultPassOptions()
87+
opts.apply(WithPassHealthCheck(2*time.Second, 2*time.Second))
88+
89+
if opts.healthCheckInterval != 2*time.Second {
90+
t.Errorf("expected 2s, got %v", opts.healthCheckInterval)
91+
}
92+
if opts.healthCheckTimeout != 2*time.Second {
93+
t.Errorf("expected 2s, got %v", opts.healthCheckTimeout)
94+
}
95+
}
96+
97+
func TestWithPassHealthCheckInvalid(t *testing.T) {
98+
opts := defaultPassOptions()
99+
opts.apply(WithPassHealthCheck(time.Millisecond*500, time.Millisecond*50))
100+
if opts.healthCheckInterval != 5*time.Second {
101+
t.Errorf("invalid interval should not be applied")
102+
}
103+
if opts.healthCheckTimeout != 3*time.Second {
104+
t.Errorf("invalid timeout should not be applied")
105+
}
106+
}
107+
108+
func TestWithPassMiddlewares(t *testing.T) {
109+
m1 := func(c *gin.Context) {}
110+
m2 := func(c *gin.Context) {}
111+
opts := defaultPassOptions()
112+
opts.apply(WithPassMiddlewares(m1, m2))
113+
114+
if len(opts.passMiddlewares) != 2 {
115+
t.Errorf("expected 2 middlewares, got %d", len(opts.passMiddlewares))
116+
}
117+
}

0 commit comments

Comments
 (0)