feat: assemble controller HTTP runtime
This commit is contained in:
parent
68f685d89b
commit
ee7fc85031
@ -28,6 +28,8 @@ Admin API 使用独立监听器与权限,契约位于 `api/openapi/admin.yaml`
|
||||
`platform/httpapi` 公用实现提供。Admin Handler 必须注入 `Authorizer`,标准
|
||||
装配使用 `httpsecurity.Protection`,并在路由匹配前完成保护。网关使用的
|
||||
`Proxy-Authorization`/407 语义不得复用到 Admin 的 `Authorization`/401 语义。
|
||||
`controller/runtime` 将 Admin 与 Distribution 放在不同 `net.Listener`,任一
|
||||
监听器异常会触发同组端点的有界优雅停机。
|
||||
|
||||
除契约中的 401/403/404/409/422 外,运行时还明确返回:
|
||||
|
||||
|
||||
@ -212,4 +212,5 @@ proxyId, clientId, sourceIP, requestId, upstream, extractedAt, expiresAt
|
||||
请求体解码、Request ID 与 Problem JSON 统一复用 `platform/httpapi`。认证、
|
||||
可信代理、来源控制、Client ID 和准入限流由必需的 `IdentityResolver` 注入,
|
||||
标准装配使用 `httpsecurity.Protection`;解析结果至少包含稳定 Client ID 或
|
||||
Source IP,且安全检查先于请求体解析。
|
||||
Source IP,且安全检查先于请求体解析。`controller/runtime` 将 Distribution
|
||||
与 Admin 装配到不同 `net.Listener`,健康端点保持公开,提取端点执行独立认证。
|
||||
|
||||
@ -13,9 +13,9 @@ proxy-pool/
|
||||
│ ├── config/ # 严格配置解析和校验
|
||||
│ ├── domain/ # 无传输、无存储依赖的领域模型
|
||||
│ ├── gateway/ # snapshot、dispatch、server、transport
|
||||
│ ├── controller/ # provider、pool、routing、extraction、health
|
||||
│ ├── controller/ # provider、pool、routing、extraction、health、runtime
|
||||
│ ├── adapters/ # PostgreSQL、Redis、Provider API、内存适配
|
||||
│ └── platform/ # 日志、指标、停机和进程装配
|
||||
│ └── platform/ # HTTP、安全、日志、指标、停机和进程装配
|
||||
├── api/ # OpenAPI 与 Protobuf 契约
|
||||
├── configs/ # 默认配置
|
||||
├── examples/ # 可校验配置场景
|
||||
@ -93,4 +93,3 @@ platform -------------------------> standard library / observability SDK
|
||||
执行方式时再抽象 egress adapter。
|
||||
- 新路由策略:实现同一策略端口,并提供确定性单测和并发不变量测试。
|
||||
- 新存储:实现已有 repository port,不把驱动类型泄漏到控制器。
|
||||
|
||||
|
||||
@ -168,8 +168,10 @@ extract/live/ready Handler 与 Admin status/enable/disable/switch/reload Handler
|
||||
定向契约测试已覆盖严格 JSON、Body 上限、Request ID、幂等 Header、DTO 映射、
|
||||
404/405 及业务错误映射。共享 `platform/httpsecurity` 已补齐 Basic/API Key/
|
||||
Bearer/CIDR、可信代理、Client ID、本地准入和 API 401/Gateway 407 差异,并作为
|
||||
Admin/Distribution 必需依赖。端点正式勾选仍等待独立监听器装配、
|
||||
PostgreSQL/Redis Adapter 与 Compose 集成测试。
|
||||
Admin/Distribution 必需依赖。共享 `platform/httpserver` 与
|
||||
`controller/runtime` 已完成 Distribution/Admin 独立监听器、首错联动关闭和
|
||||
有界优雅停机;端点正式勾选仍等待 PostgreSQL/Redis Adapter、命令入口与
|
||||
Compose 集成测试。
|
||||
|
||||
## Task 11: Checker and Health Reducer
|
||||
|
||||
|
||||
@ -38,7 +38,8 @@
|
||||
- `PROVIDER-*`:Provider HTTP Client、严格响应上限、模板解析安全边界、凭据
|
||||
引用 Store 与 Reconciler Adapter 已实现。
|
||||
- `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin
|
||||
Handler 已实现;共享认证、CIDR、可信代理、Client ID 与本地准入保护链已接入。
|
||||
Handler 已实现;共享认证、CIDR、可信代理、Client ID 与本地准入保护链已接入,
|
||||
Controller Runtime 已将二者装配到独立监听器并支持联动优雅停机。
|
||||
|
||||
## 2. 已执行验证
|
||||
|
||||
@ -68,7 +69,7 @@ CI 已配置 Linux race job。Docker/Kubernetes 仅完成静态验证,没有
|
||||
5. Redis Leader、速率限制、心跳与可重建协调适配器。
|
||||
6. Worker ownership drain/ACK/过期回收和网络快照流。
|
||||
7. Checker 调度、探测器和健康 reducer。
|
||||
8. Admin/Distribution 独立监听器装配、细粒度授权、分布式限流和审计查询。
|
||||
8. Admin/Distribution 细粒度授权、分布式限流和审计查询。
|
||||
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
|
||||
|
||||
## 4. 容量结论
|
||||
|
||||
167
internal/controller/runtime/runtime.go
Normal file
167
internal/controller/runtime/runtime.go
Normal file
@ -0,0 +1,167 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/proxy-pool/proxy-pool/internal/config"
|
||||
"github.com/proxy-pool/proxy-pool/internal/controller/admin"
|
||||
"github.com/proxy-pool/proxy-pool/internal/controller/distribution"
|
||||
"github.com/proxy-pool/proxy-pool/internal/platform/httpsecurity"
|
||||
"github.com/proxy-pool/proxy-pool/internal/platform/httpserver"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDistributionBodyLimitBytes int64 = 64 << 10
|
||||
defaultAdminBodyLimitBytes int64 = 8 << 10
|
||||
)
|
||||
|
||||
var ErrInvalidRuntime = errors.New("invalid controller HTTP runtime")
|
||||
|
||||
type Dependencies struct {
|
||||
Extractor distribution.Extractor
|
||||
Readiness distribution.ReadinessChecker
|
||||
AdminService admin.Service
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
DistributionBodyLimitBytes int64
|
||||
AdminBodyLimitBytes int64
|
||||
HTTP httpserver.Options
|
||||
}
|
||||
|
||||
type Listeners struct {
|
||||
Distribution net.Listener
|
||||
Admin net.Listener
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
distributionEnabled bool
|
||||
distributionAddress string
|
||||
distributionHandler http.Handler
|
||||
adminEnabled bool
|
||||
adminAddress string
|
||||
adminHandler http.Handler
|
||||
httpOptions httpserver.Options
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, dependencies Dependencies, options Options) (*Runtime, error) {
|
||||
if cfg == nil || (!cfg.Distribution.Enabled && !cfg.Admin.Enabled) {
|
||||
return nil, ErrInvalidRuntime
|
||||
}
|
||||
distributionBodyLimit, adminBodyLimit, err := resolveBodyLimits(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &Runtime{httpOptions: options.HTTP}
|
||||
|
||||
if cfg.Distribution.Enabled {
|
||||
if strings.TrimSpace(cfg.Distribution.Listen) == "" || dependencies.Extractor == nil || dependencies.Readiness == nil {
|
||||
return nil, ErrInvalidRuntime
|
||||
}
|
||||
protection, err := httpsecurity.BuildFromListener(
|
||||
cfg.Distribution.Listener,
|
||||
cfg.Distribution.ClientIdentification.Mode,
|
||||
httpsecurity.APIAuthSemantics,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: build distribution protection: %v", ErrInvalidRuntime, err)
|
||||
}
|
||||
handler, err := distribution.NewHandler(distribution.Config{BodyLimitBytes: distributionBodyLimit}, distribution.Dependencies{
|
||||
Extractor: dependencies.Extractor,
|
||||
Identity: protection,
|
||||
Readiness: dependencies.Readiness,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidRuntime, err)
|
||||
}
|
||||
result.distributionEnabled = true
|
||||
result.distributionAddress = cfg.Distribution.Listen
|
||||
result.distributionHandler = handler
|
||||
}
|
||||
|
||||
if cfg.Admin.Enabled {
|
||||
if strings.TrimSpace(cfg.Admin.Listen) == "" || dependencies.AdminService == nil {
|
||||
return nil, ErrInvalidRuntime
|
||||
}
|
||||
protection, err := httpsecurity.BuildFromListener(
|
||||
cfg.Admin,
|
||||
httpsecurity.ClientSourceIP,
|
||||
httpsecurity.APIAuthSemantics,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: build admin protection: %v", ErrInvalidRuntime, err)
|
||||
}
|
||||
handler, err := admin.NewHandler(dependencies.AdminService, protection, admin.Options{MaxBodyBytes: adminBodyLimit})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidRuntime, err)
|
||||
}
|
||||
result.adminEnabled = true
|
||||
result.adminAddress = cfg.Admin.Listen
|
||||
result.adminHandler = handler
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (runtime *Runtime) Run(ctx context.Context) error {
|
||||
if runtime == nil || ctx == nil {
|
||||
return ErrInvalidRuntime
|
||||
}
|
||||
bindings := make([]httpserver.Binding, 0, 2)
|
||||
if runtime.distributionEnabled {
|
||||
bindings = append(bindings, httpserver.Binding{
|
||||
Name: "distribution", Address: runtime.distributionAddress, Handler: runtime.distributionHandler,
|
||||
})
|
||||
}
|
||||
if runtime.adminEnabled {
|
||||
bindings = append(bindings, httpserver.Binding{
|
||||
Name: "admin", Address: runtime.adminAddress, Handler: runtime.adminHandler,
|
||||
})
|
||||
}
|
||||
if err := httpserver.ListenAndServe(ctx, runtime.httpOptions, bindings...); err != nil {
|
||||
return fmt.Errorf("run controller HTTP runtime: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (runtime *Runtime) Serve(ctx context.Context, listeners Listeners) error {
|
||||
if runtime == nil || ctx == nil ||
|
||||
runtime.distributionEnabled != (listeners.Distribution != nil) ||
|
||||
runtime.adminEnabled != (listeners.Admin != nil) {
|
||||
return ErrInvalidRuntime
|
||||
}
|
||||
endpoints := make([]httpserver.Endpoint, 0, 2)
|
||||
if runtime.distributionEnabled {
|
||||
endpoints = append(endpoints, httpserver.Endpoint{
|
||||
Name: "distribution", Listener: listeners.Distribution, Handler: runtime.distributionHandler,
|
||||
})
|
||||
}
|
||||
if runtime.adminEnabled {
|
||||
endpoints = append(endpoints, httpserver.Endpoint{
|
||||
Name: "admin", Listener: listeners.Admin, Handler: runtime.adminHandler,
|
||||
})
|
||||
}
|
||||
if err := httpserver.Serve(ctx, runtime.httpOptions, endpoints...); err != nil {
|
||||
return fmt.Errorf("serve controller HTTP runtime: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveBodyLimits(options Options) (int64, int64, error) {
|
||||
if options.DistributionBodyLimitBytes < 0 || options.AdminBodyLimitBytes < 0 {
|
||||
return 0, 0, ErrInvalidRuntime
|
||||
}
|
||||
distributionLimit := options.DistributionBodyLimitBytes
|
||||
if distributionLimit == 0 {
|
||||
distributionLimit = defaultDistributionBodyLimitBytes
|
||||
}
|
||||
adminLimit := options.AdminBodyLimitBytes
|
||||
if adminLimit == 0 {
|
||||
adminLimit = defaultAdminBodyLimitBytes
|
||||
}
|
||||
return distributionLimit, adminLimit, nil
|
||||
}
|
||||
199
internal/controller/runtime/runtime_test.go
Normal file
199
internal/controller/runtime/runtime_test.go
Normal file
@ -0,0 +1,199 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/proxy-pool/proxy-pool/internal/config"
|
||||
"github.com/proxy-pool/proxy-pool/internal/controller/admin"
|
||||
controllerExtraction "github.com/proxy-pool/proxy-pool/internal/controller/extraction"
|
||||
"github.com/proxy-pool/proxy-pool/internal/platform/httpserver"
|
||||
)
|
||||
|
||||
func TestRuntimeServesDistributionAndAdminOnIndependentListeners(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := runtimeConfig()
|
||||
adminService := &stubAdminService{status: admin.Status{ConfigVersion: "cfg-7", SnapshotVersion: 11}}
|
||||
runtime, err := New(cfg, Dependencies{
|
||||
Extractor: stubExtractor{},
|
||||
Readiness: stubReadiness{},
|
||||
AdminService: adminService,
|
||||
}, Options{HTTP: testHTTPOptions()})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
distributionListener := mustListen(t)
|
||||
adminListener := mustListen(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
result <- runtime.Serve(ctx, Listeners{
|
||||
Distribution: distributionListener,
|
||||
Admin: adminListener,
|
||||
})
|
||||
}()
|
||||
|
||||
distributionURL := "http://" + distributionListener.Addr().String()
|
||||
adminURL := "http://" + adminListener.Addr().String()
|
||||
assertStatus(t, http.MethodGet, distributionURL+"/health/live", nil, http.StatusOK)
|
||||
assertStatus(t, http.MethodGet, distributionURL+"/api/v1/status", nil, http.StatusNotFound)
|
||||
assertStatus(t, http.MethodGet, adminURL+"/api/v1/status", nil, http.StatusUnauthorized)
|
||||
adminHeaders := http.Header{"Authorization": []string{"Bearer admin-token"}}
|
||||
assertStatus(t, http.MethodGet, adminURL+"/api/v1/status", adminHeaders, http.StatusOK)
|
||||
assertStatus(t, http.MethodGet, adminURL+"/health/live", adminHeaders, http.StatusNotFound)
|
||||
if adminService.statusCalls.Load() != 1 {
|
||||
t.Fatalf("admin status calls = %d, want 1", adminService.statusCalls.Load())
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-result:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve() error = %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Serve() did not stop after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidRuntimeDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
validDependencies := Dependencies{
|
||||
Extractor: stubExtractor{}, Readiness: stubReadiness{}, AdminService: &stubAdminService{},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
config *config.Config
|
||||
deps Dependencies
|
||||
options Options
|
||||
}{
|
||||
{name: "nil config", deps: validDependencies},
|
||||
{name: "no HTTP listener", config: &config.Config{}, deps: validDependencies},
|
||||
{name: "distribution extractor", config: runtimeConfig(), deps: Dependencies{Readiness: stubReadiness{}, AdminService: &stubAdminService{}}},
|
||||
{name: "distribution readiness", config: runtimeConfig(), deps: Dependencies{Extractor: stubExtractor{}, AdminService: &stubAdminService{}}},
|
||||
{name: "admin service", config: runtimeConfig(), deps: Dependencies{Extractor: stubExtractor{}, Readiness: stubReadiness{}}},
|
||||
{name: "negative distribution body limit", config: runtimeConfig(), deps: validDependencies, options: Options{DistributionBodyLimitBytes: -1}},
|
||||
{name: "negative admin body limit", config: runtimeConfig(), deps: validDependencies, options: Options{AdminBodyLimitBytes: -1}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := New(test.config, test.deps, test.options); !errors.Is(err, ErrInvalidRuntime) {
|
||||
t.Fatalf("New() error = %v, want %v", err, ErrInvalidRuntime)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRequiresExactlyTheEnabledListeners(t *testing.T) {
|
||||
t.Parallel()
|
||||
runtime, err := New(runtimeConfig(), Dependencies{
|
||||
Extractor: stubExtractor{}, Readiness: stubReadiness{}, AdminService: &stubAdminService{},
|
||||
}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
listener := mustListen(t)
|
||||
defer listener.Close()
|
||||
|
||||
if err := runtime.Serve(context.Background(), Listeners{Distribution: listener}); !errors.Is(err, ErrInvalidRuntime) {
|
||||
t.Fatalf("Serve() error = %v, want %v", err, ErrInvalidRuntime)
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Distribution: config.Distribution{
|
||||
Listener: config.Listener{
|
||||
Enabled: true,
|
||||
Listen: "127.0.0.1:0",
|
||||
Auth: config.Auth{
|
||||
Mode: "apiKey", Header: "X-API-Key", Token: "distribution-token",
|
||||
},
|
||||
},
|
||||
ClientIdentification: config.ClientIdentification{Mode: "authenticatedClient"},
|
||||
},
|
||||
Admin: config.Listener{
|
||||
Enabled: true,
|
||||
Listen: "127.0.0.1:0",
|
||||
Auth: config.Auth{Mode: "bearer", Token: "admin-token"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testHTTPOptions() httpserver.Options {
|
||||
return httpserver.Options{
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
WriteTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
ShutdownTimeout: time.Second,
|
||||
MaxHeaderBytes: 16 << 10,
|
||||
}
|
||||
}
|
||||
|
||||
func mustListen(t *testing.T) net.Listener {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen(): %v", err)
|
||||
}
|
||||
return listener
|
||||
}
|
||||
|
||||
func assertStatus(t *testing.T, method, target string, headers http.Header, want int) {
|
||||
t.Helper()
|
||||
request, err := http.NewRequest(method, target, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest(): %v", err)
|
||||
}
|
||||
request.Header = headers.Clone()
|
||||
client := &http.Client{Timeout: time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Do(%s): %v", target, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != want {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
t.Fatalf("%s status = %d, want %d; body=%s", target, response.StatusCode, want, body)
|
||||
}
|
||||
}
|
||||
|
||||
type stubExtractor struct{}
|
||||
|
||||
func (stubExtractor) Extract(context.Context, controllerExtraction.Request) (controllerExtraction.Response, error) {
|
||||
return controllerExtraction.Response{}, nil
|
||||
}
|
||||
|
||||
type stubReadiness struct{}
|
||||
|
||||
func (stubReadiness) Ready(context.Context) error { return nil }
|
||||
|
||||
type stubAdminService struct {
|
||||
status admin.Status
|
||||
statusCalls atomic.Int64
|
||||
}
|
||||
|
||||
func (service *stubAdminService) Status(context.Context) (admin.Status, error) {
|
||||
service.statusCalls.Add(1)
|
||||
return service.status, nil
|
||||
}
|
||||
|
||||
func (*stubAdminService) SetUpstreamEnabled(context.Context, admin.SetUpstreamCommand) (admin.MutationResult, error) {
|
||||
return admin.MutationResult{}, nil
|
||||
}
|
||||
|
||||
func (*stubAdminService) SwitchRouting(context.Context, admin.SwitchCommand) (admin.MutationResult, error) {
|
||||
return admin.MutationResult{}, nil
|
||||
}
|
||||
|
||||
func (*stubAdminService) ReloadConfiguration(context.Context, admin.ReloadCommand) (admin.MutationResult, error) {
|
||||
return admin.MutationResult{}, nil
|
||||
}
|
||||
197
internal/platform/httpserver/server.go
Normal file
197
internal/platform/httpserver/server.go
Normal file
@ -0,0 +1,197 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidEndpoint = errors.New("invalid HTTP endpoint")
|
||||
ErrInvalidOptions = errors.New("invalid HTTP server options")
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
ReadHeaderTimeout time.Duration
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
MaxHeaderBytes int
|
||||
}
|
||||
|
||||
type Endpoint struct {
|
||||
Name string
|
||||
Listener net.Listener
|
||||
Handler http.Handler
|
||||
}
|
||||
|
||||
type Binding struct {
|
||||
Name string
|
||||
Address string
|
||||
Handler http.Handler
|
||||
}
|
||||
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
ShutdownTimeout: 15 * time.Second,
|
||||
MaxHeaderBytes: 16 << 10,
|
||||
}
|
||||
}
|
||||
|
||||
func Serve(ctx context.Context, options Options, endpoints ...Endpoint) error {
|
||||
if ctx == nil || len(endpoints) == 0 {
|
||||
return ErrInvalidEndpoint
|
||||
}
|
||||
resolved, err := resolveOptions(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen := make(map[string]struct{}, len(endpoints))
|
||||
servers := make([]*http.Server, 0, len(endpoints))
|
||||
for _, endpoint := range endpoints {
|
||||
if endpoint.Name == "" || endpoint.Listener == nil || endpoint.Handler == nil {
|
||||
return ErrInvalidEndpoint
|
||||
}
|
||||
if _, exists := seen[endpoint.Name]; exists {
|
||||
return fmt.Errorf("%w: duplicate name %q", ErrInvalidEndpoint, endpoint.Name)
|
||||
}
|
||||
seen[endpoint.Name] = struct{}{}
|
||||
servers = append(servers, &http.Server{
|
||||
Handler: endpoint.Handler,
|
||||
ReadHeaderTimeout: resolved.ReadHeaderTimeout,
|
||||
ReadTimeout: resolved.ReadTimeout,
|
||||
WriteTimeout: resolved.WriteTimeout,
|
||||
IdleTimeout: resolved.IdleTimeout,
|
||||
MaxHeaderBytes: resolved.MaxHeaderBytes,
|
||||
})
|
||||
}
|
||||
|
||||
type serveResult struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
results := make(chan serveResult, len(endpoints))
|
||||
for index, endpoint := range endpoints {
|
||||
server := servers[index]
|
||||
go func() {
|
||||
results <- serveResult{name: endpoint.Name, err: server.Serve(endpoint.Listener)}
|
||||
}()
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
received := 0
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case result := <-results:
|
||||
received++
|
||||
if !errors.Is(result.err, http.ErrServerClosed) {
|
||||
firstErr = fmt.Errorf("serve HTTP endpoint %q: %w", result.name, result.err)
|
||||
}
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), resolved.ShutdownTimeout)
|
||||
defer cancel()
|
||||
shutdownErrors := make(chan error, len(servers))
|
||||
var wait sync.WaitGroup
|
||||
for _, server := range servers {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
if shutdownErr := server.Shutdown(shutdownCtx); shutdownErr != nil {
|
||||
_ = server.Close()
|
||||
shutdownErrors <- shutdownErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
close(shutdownErrors)
|
||||
if firstErr == nil {
|
||||
for shutdownErr := range shutdownErrors {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("shutdown HTTP endpoints: %w", shutdownErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for received < len(endpoints) {
|
||||
result := <-results
|
||||
received++
|
||||
if firstErr == nil && !errors.Is(result.err, http.ErrServerClosed) {
|
||||
firstErr = fmt.Errorf("serve HTTP endpoint %q: %w", result.name, result.err)
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func ListenAndServe(ctx context.Context, options Options, bindings ...Binding) error {
|
||||
if ctx == nil || len(bindings) == 0 {
|
||||
return ErrInvalidEndpoint
|
||||
}
|
||||
if _, err := resolveOptions(options); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := make(map[string]struct{}, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
if binding.Name == "" || binding.Address == "" || binding.Handler == nil {
|
||||
return ErrInvalidEndpoint
|
||||
}
|
||||
if _, exists := seen[binding.Name]; exists {
|
||||
return fmt.Errorf("%w: duplicate name %q", ErrInvalidEndpoint, binding.Name)
|
||||
}
|
||||
seen[binding.Name] = struct{}{}
|
||||
}
|
||||
endpoints := make([]Endpoint, 0, len(bindings))
|
||||
defer func() { closeEndpoints(endpoints) }()
|
||||
for _, binding := range bindings {
|
||||
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", binding.Address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen HTTP endpoint %q: %w", binding.Name, err)
|
||||
}
|
||||
endpoints = append(endpoints, Endpoint{
|
||||
Name: binding.Name, Listener: listener, Handler: binding.Handler,
|
||||
})
|
||||
}
|
||||
return Serve(ctx, options, endpoints...)
|
||||
}
|
||||
|
||||
func resolveOptions(options Options) (Options, error) {
|
||||
defaults := DefaultOptions()
|
||||
if options.ReadHeaderTimeout == 0 {
|
||||
options.ReadHeaderTimeout = defaults.ReadHeaderTimeout
|
||||
}
|
||||
if options.ReadTimeout == 0 {
|
||||
options.ReadTimeout = defaults.ReadTimeout
|
||||
}
|
||||
if options.WriteTimeout == 0 {
|
||||
options.WriteTimeout = defaults.WriteTimeout
|
||||
}
|
||||
if options.IdleTimeout == 0 {
|
||||
options.IdleTimeout = defaults.IdleTimeout
|
||||
}
|
||||
if options.ShutdownTimeout == 0 {
|
||||
options.ShutdownTimeout = defaults.ShutdownTimeout
|
||||
}
|
||||
if options.MaxHeaderBytes == 0 {
|
||||
options.MaxHeaderBytes = defaults.MaxHeaderBytes
|
||||
}
|
||||
if options.ReadHeaderTimeout < 0 || options.ReadTimeout < 0 || options.WriteTimeout < 0 ||
|
||||
options.IdleTimeout < 0 || options.ShutdownTimeout <= 0 || options.MaxHeaderBytes < 0 {
|
||||
return Options{}, ErrInvalidOptions
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func closeEndpoints(endpoints []Endpoint) {
|
||||
for _, endpoint := range endpoints {
|
||||
_ = endpoint.Listener.Close()
|
||||
}
|
||||
}
|
||||
158
internal/platform/httpserver/server_test.go
Normal file
158
internal/platform/httpserver/server_test.go
Normal file
@ -0,0 +1,158 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestServeRunsIndependentEndpointsAndShutsDownTogether(t *testing.T) {
|
||||
t.Parallel()
|
||||
first := mustListen(t)
|
||||
second := mustListen(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
result := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
result <- Serve(ctx, testOptions(),
|
||||
Endpoint{Name: "distribution", Listener: first, Handler: textHandler("distribution")},
|
||||
Endpoint{Name: "admin", Listener: second, Handler: textHandler("admin")},
|
||||
)
|
||||
}()
|
||||
|
||||
assertBody(t, first.Addr().String(), "distribution")
|
||||
assertBody(t, second.Addr().String(), "admin")
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-result:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve() error = %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Serve() did not stop after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRejectsInvalidEndpointDefinitions(t *testing.T) {
|
||||
t.Parallel()
|
||||
listener := mustListen(t)
|
||||
defer listener.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoints []Endpoint
|
||||
}{
|
||||
{name: "empty"},
|
||||
{name: "missing name", endpoints: []Endpoint{{Listener: listener, Handler: textHandler("ok")}}},
|
||||
{name: "missing listener", endpoints: []Endpoint{{Name: "api", Handler: textHandler("ok")}}},
|
||||
{name: "missing handler", endpoints: []Endpoint{{Name: "api", Listener: listener}}},
|
||||
{name: "duplicate name", endpoints: []Endpoint{
|
||||
{Name: "api", Listener: listener, Handler: textHandler("one")},
|
||||
{Name: "api", Listener: listener, Handler: textHandler("two")},
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := Serve(context.Background(), testOptions(), test.endpoints...)
|
||||
if !errors.Is(err, ErrInvalidEndpoint) {
|
||||
t.Fatalf("Serve() error = %v, want %v", err, ErrInvalidEndpoint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStopsSiblingWhenEndpointFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
failed := mustListen(t)
|
||||
sibling := mustListen(t)
|
||||
failedAddress := failed.Addr().String()
|
||||
if err := failed.Close(); err != nil {
|
||||
t.Fatalf("Close(%s): %v", failedAddress, err)
|
||||
}
|
||||
|
||||
err := Serve(context.Background(), testOptions(),
|
||||
Endpoint{Name: "failed", Listener: failed, Handler: textHandler("failed")},
|
||||
Endpoint{Name: "sibling", Listener: sibling, Handler: textHandler("sibling")},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("Serve() error = nil, want endpoint failure")
|
||||
}
|
||||
connection, dialErr := net.DialTimeout("tcp", sibling.Addr().String(), 100*time.Millisecond)
|
||||
if dialErr == nil {
|
||||
connection.Close()
|
||||
t.Fatal("sibling listener remained open after endpoint failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAndServeValidatesAllBindingsBeforeOpeningSockets(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := ListenAndServe(context.Background(), testOptions(),
|
||||
Binding{Name: "api", Address: "\x00", Handler: textHandler("one")},
|
||||
Binding{Name: "api", Address: "127.0.0.1:0", Handler: textHandler("two")},
|
||||
)
|
||||
if !errors.Is(err, ErrInvalidEndpoint) {
|
||||
t.Fatalf("ListenAndServe() error = %v, want %v", err, ErrInvalidEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAndServeTreatsCancellationBeforeBindingAsGracefulStop(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := ListenAndServe(ctx, testOptions(), Binding{
|
||||
Name: "api", Address: "127.0.0.1:0", Handler: textHandler("ok"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListenAndServe() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testOptions() Options {
|
||||
return Options{
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
WriteTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
ShutdownTimeout: time.Second,
|
||||
MaxHeaderBytes: 16 << 10,
|
||||
}
|
||||
}
|
||||
|
||||
func mustListen(t *testing.T) net.Listener {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen(): %v", err)
|
||||
}
|
||||
return listener
|
||||
}
|
||||
|
||||
func textHandler(body string) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(writer, body)
|
||||
})
|
||||
}
|
||||
|
||||
func assertBody(t *testing.T, address, want string) {
|
||||
t.Helper()
|
||||
client := &http.Client{Timeout: time.Second}
|
||||
response, err := client.Get("http://" + address)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", address, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll(): %v", err)
|
||||
}
|
||||
if string(body) != want {
|
||||
t.Fatalf("body = %q, want %q", body, want)
|
||||
}
|
||||
}
|
||||
10
progress.md
10
progress.md
@ -1,5 +1,15 @@
|
||||
# 项目进度
|
||||
|
||||
## 2026-07-29
|
||||
|
||||
- 已实现共享 `httpapi`、`httpsecurity` 与 `httpserver`,统一严格 JSON、Problem、
|
||||
Request ID、认证、可信代理、Client ID、准入、多监听器生命周期和优雅停机。
|
||||
- Distribution/Admin Handler 已装配到独立监听器;Gateway 配置认证统一复用
|
||||
`httpsecurity`,API 401 与代理 407 语义保持分离。
|
||||
- 部署配置已使用 `${VAR}` 真实环境变量解析,ConfigMap 与本地配置通过
|
||||
`LoadResolved` 回归测试。
|
||||
- PostgreSQL/Redis Adapter、生产命令入口与代表性 100,000 QPS 集群压测仍待实现。
|
||||
|
||||
## 2026-07-28
|
||||
|
||||
- 用户删除了先前基于不完整网页内容生成的设计文件。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user