refactor(gateway): extract bootstrap and provider registry

This commit is contained in:
Your Name
2026-04-14 10:45:30 +08:00
parent d28f83a6a8
commit cd26802d7d
9 changed files with 437 additions and 148 deletions

View File

@@ -9,7 +9,7 @@
- 鉴权运行时支持两种模式:
- `inmemory`
- `remote_introspection`
- 当前 provider 注册仍是代码内装配,不是配置驱动。默认只在入口里注册 OpenAI adapter。
- provider 注册已通过 [config.LoadConfig](/home/long/project/立交桥/gateway/internal/config/config.go) 提供的 `Providers` 配置装配;默认会基于环境变量生成一个 OpenAI provider。
- 审计发射器支持 PostgreSQL 与内存实现;数据库未配置时会回退到内存实现。
## 目录结构
@@ -38,6 +38,8 @@ gateway/
```bash
export OPENAI_API_KEY="..."
export OPENAI_BASE_URL="https://api.openai.com"
export OPENAI_MODELS="gpt-4,gpt-3.5-turbo"
export GATEWAY_ENV="dev"
export GATEWAY_TOKEN_RUNTIME_MODE="inmemory"
```
@@ -76,5 +78,5 @@ bash scripts/ci/repo_integrity_check.sh
## 已知限制
- `internal/config.Config.Providers` 已存在,但当前入口尚未按该配置动态注册 provider。
- 生产级 provider 装配和更细的 bootstrap 拆分,仍在后续重构计划中。
- 当前默认 provider 只有 OpenAI其他 provider 类型还未接入到 `internal/app/providers.go`
- 更细的生产级 bootstrap 和多 provider 装配,仍在后续重构计划中。

View File

@@ -2,7 +2,6 @@ package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
@@ -10,12 +9,8 @@ import (
"syscall"
"time"
"lijiaoqiao/gateway/internal/adapter"
"lijiaoqiao/gateway/internal/app"
"lijiaoqiao/gateway/internal/config"
"lijiaoqiao/gateway/internal/handler"
"lijiaoqiao/gateway/internal/middleware"
"lijiaoqiao/gateway/internal/ratelimit"
"lijiaoqiao/gateway/internal/router"
)
func main() {
@@ -25,105 +20,14 @@ func main() {
log.Fatalf("Failed to load config: %v", err)
}
// 初始化Router
r := router.NewRouter(router.StrategyLatency)
// 注册Provider (示例: OpenAI)
openaiAdapter := adapter.NewOpenAIAdapter(
"https://api.openai.com",
os.Getenv("OPENAI_API_KEY"),
[]string{"gpt-4", "gpt-3.5-turbo"},
)
r.RegisterProvider("openai", openaiAdapter)
// 初始化限流中间件
var limiterMiddleware *ratelimit.Middleware
if cfg.RateLimit.Algorithm == "token_bucket" {
limiter := ratelimit.NewTokenBucketLimiter(
cfg.RateLimit.DefaultRPM,
cfg.RateLimit.DefaultTPM,
cfg.RateLimit.BurstMultiplier,
)
limiterMiddleware = ratelimit.NewMiddleware(limiter)
} else {
limiter := ratelimit.NewSlidingWindowLimiter(
time.Minute,
cfg.RateLimit.DefaultRPM,
)
limiterMiddleware = ratelimit.NewMiddleware(limiter)
}
// 初始化审计发射器
var auditor middleware.AuditEmitter
if cfg.Database.Host != "" {
// MED-10: 使用 GetPassword() 获取解密后的密码,避免在日志中暴露明文密码
dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
cfg.Database.User,
cfg.Database.GetPassword(),
cfg.Database.Host,
cfg.Database.Port,
cfg.Database.Database,
)
auditEmitter, err := middleware.NewDatabaseAuditEmitter(dsn, time.Now)
if err != nil {
log.Printf("Warning: Failed to create database audit emitter: %v, using memory emitter", err)
auditor = middleware.NewMemoryAuditEmitter()
} else {
auditor = auditEmitter
defer auditEmitter.Close()
}
} else {
log.Printf("Warning: Database not configured, using memory audit emitter")
auditor = middleware.NewMemoryAuditEmitter()
}
// 初始化 token 运行时
var tokenRuntime interface {
middleware.TokenVerifier
middleware.TokenStatusResolver
}
switch cfg.Auth.TokenRuntimeMode {
case "inmemory":
tokenRuntime = middleware.NewInMemoryTokenRuntime(time.Now)
case "remote_introspection":
tokenRuntime = middleware.NewRemoteTokenRuntime(cfg.Auth.TokenRuntimeURL, http.DefaultClient, time.Now)
default:
log.Fatalf("unsupported token runtime mode: %s", cfg.Auth.TokenRuntimeMode)
}
// 构建认证中间件配置
authMiddlewareConfig := middleware.AuthMiddlewareConfig{
Verifier: tokenRuntime,
StatusResolver: tokenRuntime,
Authorizer: middleware.NewScopeRoleAuthorizer(),
Auditor: auditor,
ProtectedPrefixes: []string{
"/v1/chat/completions",
"/v1/completions",
"/api/v1/chat/completions",
"/api/v1/completions",
"/api/v1/supply",
"/api/v1/platform",
},
ExcludedPrefixes: []string{"/health", "/healthz", "/metrics", "/readyz"},
Now: time.Now,
}
// 初始化Handler
h := handler.NewHandler(r)
// 创建Server
server := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
Handler: createMux(h, limiterMiddleware, authMiddlewareConfig),
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
IdleTimeout: cfg.Server.IdleTimeout,
server, err := app.BuildServer(cfg)
if err != nil {
log.Fatalf("Failed to build server: %v", err)
}
// 启动Server
go func() {
log.Printf("Starting gateway server on %s:%d", cfg.Server.Host, cfg.Server.Port)
log.Printf("Starting gateway server on %s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
@@ -146,42 +50,3 @@ func main() {
log.Println("Server exited")
}
func createMux(h *handler.Handler, limiter *ratelimit.Middleware, authConfig middleware.AuthMiddlewareConfig) http.Handler {
mux := http.NewServeMux()
chatHandler := middleware.BuildTokenAuthChain(authConfig, http.HandlerFunc(h.ChatCompletionsHandle))
completionsHandler := middleware.BuildTokenAuthChain(authConfig, http.HandlerFunc(h.CompletionsHandle))
mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
limitHandler(limiter, chatHandler).ServeHTTP(w, r)
})
mux.HandleFunc("/v1/completions", func(w http.ResponseWriter, r *http.Request) {
limitHandler(limiter, completionsHandler).ServeHTTP(w, r)
})
mux.HandleFunc("/v1/models", h.ModelsHandle)
mux.HandleFunc("/api/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
limitHandler(limiter, chatHandler).ServeHTTP(w, r)
})
mux.HandleFunc("/api/v1/completions", func(w http.ResponseWriter, r *http.Request) {
limitHandler(limiter, completionsHandler).ServeHTTP(w, r)
})
mux.HandleFunc("/health", h.HealthHandle)
mux.HandleFunc("/healthz", h.HealthHandle)
mux.HandleFunc("/readyz", h.HealthHandle)
return middleware.CORSMiddleware(middleware.DefaultCORSConfig())(mux)
}
func limitHandler(limiter *ratelimit.Middleware, next http.Handler) http.Handler {
if limiter == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limiter.Limit(next.ServeHTTP)(w, r)
})
}

View File

@@ -11,6 +11,7 @@ import (
"time"
"lijiaoqiao/gateway/internal/adapter"
"lijiaoqiao/gateway/internal/app"
"lijiaoqiao/gateway/internal/handler"
"lijiaoqiao/gateway/internal/middleware"
"lijiaoqiao/gateway/internal/ratelimit"
@@ -85,7 +86,7 @@ func TestCreateMux_ProtectsCompletionRoutes(t *testing.T) {
Now: func() time.Time { return now },
}
mux := createMux(h, limiter, authConfig)
mux := app.BuildMux(h, limiter, authConfig)
for _, path := range []string{
"/v1/chat/completions",
@@ -118,7 +119,7 @@ func TestCreateMux_HealthRoutesRemainOpen(t *testing.T) {
Now: func() time.Time { return now },
}
mux := createMux(h, limiter, authConfig)
mux := app.BuildMux(h, limiter, authConfig)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
@@ -153,7 +154,7 @@ func TestCreateMux_CompletionsRouteUsesCompletionsHandler(t *testing.T) {
Now: func() time.Time { return now },
}
mux := createMux(h, limiter, authConfig)
mux := app.BuildMux(h, limiter, authConfig)
reqBody := `{"model":"gpt-4","prompt":"hello","max_tokens":16}`
req := httptest.NewRequest(http.MethodPost, "/v1/completions", bytes.NewBufferString(reqBody))
req.Header.Set("Authorization", "Bearer "+token)

View File

@@ -0,0 +1,222 @@
package app
import (
"fmt"
"net/http"
"strings"
"time"
"lijiaoqiao/gateway/internal/config"
"lijiaoqiao/gateway/internal/handler"
"lijiaoqiao/gateway/internal/middleware"
"lijiaoqiao/gateway/internal/ratelimit"
"lijiaoqiao/gateway/internal/router"
)
func BuildServer(cfg *config.Config) (*http.Server, error) {
if cfg == nil {
return nil, fmt.Errorf("config is required")
}
normalized := normalizeConfig(*cfg)
if err := config.ValidateAuthConfig(normalized.Auth); err != nil {
return nil, err
}
r, err := buildRouter(&normalized)
if err != nil {
return nil, err
}
limiter := buildLimiter(normalized.RateLimit)
auditor, err := buildAuditor(normalized)
if err != nil {
return nil, err
}
tokenRuntime, err := buildTokenRuntime(normalized.Auth)
if err != nil {
return nil, err
}
authConfig := middleware.AuthMiddlewareConfig{
Verifier: tokenRuntime,
StatusResolver: tokenRuntime,
Authorizer: middleware.NewScopeRoleAuthorizer(),
Auditor: auditor,
ProtectedPrefixes: []string{
"/v1/chat/completions",
"/v1/completions",
"/api/v1/chat/completions",
"/api/v1/completions",
"/api/v1/supply",
"/api/v1/platform",
},
ExcludedPrefixes: []string{"/health", "/healthz", "/metrics", "/readyz"},
Now: time.Now,
}
h := handler.NewHandler(r)
server := &http.Server{
Addr: fmt.Sprintf("%s:%d", normalized.Server.Host, normalized.Server.Port),
Handler: BuildMux(h, limiter, authConfig),
ReadTimeout: normalized.Server.ReadTimeout,
WriteTimeout: normalized.Server.WriteTimeout,
IdleTimeout: normalized.Server.IdleTimeout,
}
return server, nil
}
func BuildMux(h *handler.Handler, limiter *ratelimit.Middleware, authConfig middleware.AuthMiddlewareConfig) http.Handler {
mux := http.NewServeMux()
chatHandler := middleware.BuildTokenAuthChain(authConfig, http.HandlerFunc(h.ChatCompletionsHandle))
completionsHandler := middleware.BuildTokenAuthChain(authConfig, http.HandlerFunc(h.CompletionsHandle))
mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
limitHandler(limiter, chatHandler).ServeHTTP(w, r)
})
mux.HandleFunc("/v1/completions", func(w http.ResponseWriter, r *http.Request) {
limitHandler(limiter, completionsHandler).ServeHTTP(w, r)
})
mux.HandleFunc("/v1/models", h.ModelsHandle)
mux.HandleFunc("/api/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
limitHandler(limiter, chatHandler).ServeHTTP(w, r)
})
mux.HandleFunc("/api/v1/completions", func(w http.ResponseWriter, r *http.Request) {
limitHandler(limiter, completionsHandler).ServeHTTP(w, r)
})
mux.HandleFunc("/health", h.HealthHandle)
mux.HandleFunc("/healthz", h.HealthHandle)
mux.HandleFunc("/readyz", h.HealthHandle)
return middleware.CORSMiddleware(middleware.DefaultCORSConfig())(mux)
}
func buildRouter(cfg *config.Config) (*router.Router, error) {
if len(cfg.Providers) == 0 {
return nil, fmt.Errorf("at least one provider must be configured")
}
r := router.NewRouter(resolveStrategy(cfg.Router.Strategy))
for _, providerCfg := range cfg.Providers {
provider, err := buildProvider(providerCfg)
if err != nil {
return nil, err
}
r.RegisterProvider(providerCfg.Name, provider)
}
return r, nil
}
func buildLimiter(cfg config.RateLimitConfig) *ratelimit.Middleware {
if strings.EqualFold(cfg.Algorithm, "sliding_window") {
limiter := ratelimit.NewSlidingWindowLimiter(time.Minute, cfg.DefaultRPM)
return ratelimit.NewMiddleware(limiter)
}
limiter := ratelimit.NewTokenBucketLimiter(cfg.DefaultRPM, cfg.DefaultTPM, cfg.BurstMultiplier)
return ratelimit.NewMiddleware(limiter)
}
func buildAuditor(cfg config.Config) (middleware.AuditEmitter, error) {
if strings.TrimSpace(cfg.Database.Host) == "" {
return middleware.NewMemoryAuditEmitter(), nil
}
dsn := fmt.Sprintf(
"postgres://%s:%s@%s:%d/%s?sslmode=disable",
cfg.Database.User,
cfg.Database.GetPassword(),
cfg.Database.Host,
cfg.Database.Port,
cfg.Database.Database,
)
auditor, err := middleware.NewDatabaseAuditEmitter(dsn, time.Now)
if err != nil {
return nil, fmt.Errorf("create database audit emitter: %w", err)
}
return auditor, nil
}
func buildTokenRuntime(cfg config.AuthConfig) (interface {
middleware.TokenVerifier
middleware.TokenStatusResolver
}, error) {
switch strings.ToLower(strings.TrimSpace(cfg.TokenRuntimeMode)) {
case "", "inmemory":
return middleware.NewInMemoryTokenRuntime(time.Now), nil
case "remote_introspection":
return middleware.NewRemoteTokenRuntime(cfg.TokenRuntimeURL, http.DefaultClient, time.Now), nil
default:
return nil, fmt.Errorf("unsupported token runtime mode: %s", cfg.TokenRuntimeMode)
}
}
func resolveStrategy(strategy string) router.LoadBalancerStrategy {
switch strings.ToLower(strings.TrimSpace(strategy)) {
case string(router.StrategyRoundRobin):
return router.StrategyRoundRobin
case string(router.StrategyWeighted):
return router.StrategyWeighted
case string(router.StrategyAvailability):
return router.StrategyAvailability
default:
return router.StrategyLatency
}
}
func normalizeConfig(cfg config.Config) config.Config {
if strings.TrimSpace(cfg.Server.Host) == "" {
cfg.Server.Host = "0.0.0.0"
}
if cfg.Server.Port == 0 {
cfg.Server.Port = 8080
}
if cfg.Server.ReadTimeout == 0 {
cfg.Server.ReadTimeout = 30 * time.Second
}
if cfg.Server.WriteTimeout == 0 {
cfg.Server.WriteTimeout = 30 * time.Second
}
if cfg.Server.IdleTimeout == 0 {
cfg.Server.IdleTimeout = 120 * time.Second
}
if cfg.Router.Strategy == "" {
cfg.Router.Strategy = string(router.StrategyLatency)
}
if cfg.RateLimit.DefaultRPM == 0 {
cfg.RateLimit.DefaultRPM = 60
}
if cfg.RateLimit.DefaultTPM == 0 {
cfg.RateLimit.DefaultTPM = 60000
}
if cfg.RateLimit.BurstMultiplier == 0 {
cfg.RateLimit.BurstMultiplier = 1.5
}
if cfg.RateLimit.Algorithm == "" {
cfg.RateLimit.Algorithm = "token_bucket"
}
if cfg.Auth.TokenRuntimeMode == "" {
cfg.Auth.TokenRuntimeMode = "inmemory"
}
return cfg
}
func limitHandler(limiter *ratelimit.Middleware, next http.Handler) http.Handler {
if limiter == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limiter.Limit(next.ServeHTTP)(w, r)
})
}

View File

@@ -0,0 +1,89 @@
package app
import (
"net/http"
"testing"
"lijiaoqiao/gateway/internal/config"
)
func TestBuildServer_FromConfigProviders(t *testing.T) {
cfg := &config.Config{
Providers: []config.ProviderConfig{{
Name: "openai",
Type: "openai",
BaseURL: "https://api.openai.com",
APIKey: "secret",
Models: []string{"gpt-4o"},
}},
}
server, err := BuildServer(cfg)
if err != nil {
t.Fatalf("BuildServer returned error: %v", err)
}
if server == nil {
t.Fatal("expected server")
}
if server.Addr != "0.0.0.0:8080" {
t.Fatalf("unexpected addr: %s", server.Addr)
}
}
func TestBuildServer_RejectsEmptyProviderList(t *testing.T) {
_, err := BuildServer(&config.Config{})
if err == nil {
t.Fatal("expected error")
}
}
func TestBuildMux_HealthRouteRemainsOpen(t *testing.T) {
cfg := &config.Config{
Providers: []config.ProviderConfig{{
Name: "openai",
Type: "openai",
BaseURL: "https://api.openai.com",
APIKey: "secret",
Models: []string{"gpt-4o"},
}},
}
server, err := BuildServer(cfg)
if err != nil {
t.Fatalf("BuildServer returned error: %v", err)
}
req, err := http.NewRequest(http.MethodGet, "/health", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
rr := newTestResponseRecorder()
server.Handler.ServeHTTP(rr, req)
if rr.code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.code)
}
}
type testResponseRecorder struct {
header http.Header
code int
}
func newTestResponseRecorder() *testResponseRecorder {
return &testResponseRecorder{header: make(http.Header)}
}
func (r *testResponseRecorder) Header() http.Header {
return r.header
}
func (r *testResponseRecorder) WriteHeader(statusCode int) {
r.code = statusCode
}
func (r *testResponseRecorder) Write(b []byte) (int, error) {
if r.code == 0 {
r.code = http.StatusOK
}
return len(b), nil
}

View File

@@ -0,0 +1,28 @@
package app
import (
"fmt"
"strings"
"lijiaoqiao/gateway/internal/adapter"
"lijiaoqiao/gateway/internal/config"
)
func buildProvider(providerCfg config.ProviderConfig) (adapter.ProviderAdapter, error) {
name := strings.TrimSpace(providerCfg.Name)
if name == "" {
return nil, fmt.Errorf("provider name is required")
}
providerType := strings.ToLower(strings.TrimSpace(providerCfg.Type))
switch providerType {
case "", "openai":
baseURL := strings.TrimSpace(providerCfg.BaseURL)
if baseURL == "" {
baseURL = "https://api.openai.com"
}
return adapter.NewOpenAIAdapter(baseURL, providerCfg.APIKey, providerCfg.Models), nil
default:
return nil, fmt.Errorf("unsupported provider type %q", providerCfg.Type)
}
}

View File

@@ -0,0 +1,33 @@
package app
import (
"testing"
"lijiaoqiao/gateway/internal/config"
)
func TestBuildProvider_OpenAI(t *testing.T) {
provider, err := buildProvider(config.ProviderConfig{
Name: "openai",
Type: "openai",
BaseURL: "https://api.openai.com",
APIKey: "secret",
Models: []string{"gpt-4o"},
})
if err != nil {
t.Fatalf("buildProvider returned error: %v", err)
}
if provider.ProviderName() != "openai" {
t.Fatalf("unexpected provider name: %s", provider.ProviderName())
}
}
func TestBuildProvider_RejectsUnsupportedType(t *testing.T) {
_, err := buildProvider(config.ProviderConfig{
Name: "anthropic",
Type: "anthropic",
})
if err == nil {
t.Fatal("expected error")
}
}

View File

@@ -202,16 +202,27 @@ func LoadConfig(path string) (*Config, error) {
Secret: getEnv("FEISHU_SECRET", ""),
},
},
Providers: []ProviderConfig{
{
Name: "openai",
Type: "openai",
BaseURL: strings.TrimSpace(getEnv("OPENAI_BASE_URL", "https://api.openai.com")),
APIKey: getEnv("OPENAI_API_KEY", ""),
Models: splitCSV(getEnv("OPENAI_MODELS", "gpt-4,gpt-3.5-turbo")),
Priority: 1,
Weight: 1.0,
},
},
}
if err := validateAuthConfig(cfg.Auth); err != nil {
if err := ValidateAuthConfig(cfg.Auth); err != nil {
return nil, err
}
return cfg, nil
}
func validateAuthConfig(cfg AuthConfig) error {
func ValidateAuthConfig(cfg AuthConfig) error {
mode := strings.ToLower(strings.TrimSpace(cfg.TokenRuntimeMode))
env := strings.ToLower(strings.TrimSpace(cfg.Env))
@@ -231,6 +242,18 @@ func validateAuthConfig(cfg AuthConfig) error {
return nil
}
func splitCSV(value string) []string {
rawParts := strings.Split(value, ",")
parts := make([]string, 0, len(rawParts))
for _, part := range rawParts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
parts = append(parts, trimmed)
}
}
return parts
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value

View File

@@ -433,3 +433,29 @@ func TestLoadConfig_ProdRequiresTokenRuntimeURL(t *testing.T) {
t.Fatalf("expected error to mention TOKEN_RUNTIME_URL, got %v", err)
}
}
func TestValidateAuthConfig_ProdRequiresRemoteIntrospection(t *testing.T) {
cfg := AuthConfig{Env: "prod", TokenRuntimeMode: "inmemory"}
if err := ValidateAuthConfig(cfg); err == nil {
t.Fatal("expected error")
}
}
func TestLoadConfig_DefaultProvider(t *testing.T) {
t.Setenv("OPENAI_BASE_URL", "https://api.openai.com")
t.Setenv("OPENAI_MODELS", "gpt-4o-mini,gpt-4o")
cfg, err := LoadConfig("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Providers) != 1 {
t.Fatalf("expected 1 provider, got %d", len(cfg.Providers))
}
if cfg.Providers[0].Name != "openai" {
t.Fatalf("unexpected provider name: %s", cfg.Providers[0].Name)
}
if len(cfg.Providers[0].Models) != 2 {
t.Fatalf("unexpected model count: %d", len(cfg.Providers[0].Models))
}
}