feat: bootstrap reusable GoOmni framework
This commit is contained in:
17
health/README.md
Normal file
17
health/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# health
|
||||
|
||||
`health` 提供与网络层无关的存活和就绪 Handler。业务项目只需要注册依赖探针,路由仍通过 `omni.App` 或项目自己的 transport 注册:
|
||||
|
||||
```go
|
||||
checker, err := health.New(health.Probe{
|
||||
Name: "database",
|
||||
Check: db.PingContext,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.GET("/health/live", checker.LivenessHandler())
|
||||
app.GET("/health/ready", checker.ReadinessHandler())
|
||||
```
|
||||
|
||||
就绪探针会保留每项检查结果,并在至少一项失败时返回 `503` 和 `NOT_READY`。
|
||||
153
health/health.go
Normal file
153
health/health.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Package health provides transport-neutral liveness and readiness handlers.
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusReady = "ready"
|
||||
StatusNotReady = "not_ready"
|
||||
)
|
||||
|
||||
// Probe is one dependency check used by readiness. Timeout is optional; a
|
||||
// zero value lets the request context control the probe duration.
|
||||
type Probe struct {
|
||||
Name string
|
||||
Check func(context.Context) error
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// CheckResult is the public result of one readiness probe.
|
||||
type CheckResult struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DurationMs float64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
// Result is returned by Checker.Check and the readiness endpoint.
|
||||
type Result struct {
|
||||
Status string `json:"status"`
|
||||
Checks []CheckResult `json:"checks,omitempty"`
|
||||
}
|
||||
|
||||
func (r Result) Ready() bool { return r.Status == StatusReady }
|
||||
|
||||
// Checker owns the probes registered by a service. It can be shared by all
|
||||
// requests after startup; registration is safe before or during serving.
|
||||
type Checker struct {
|
||||
mu sync.RWMutex
|
||||
probes []Probe
|
||||
}
|
||||
|
||||
// New creates a checker and validates its initial probes.
|
||||
func New(probes ...Probe) (*Checker, error) {
|
||||
checker := &Checker{}
|
||||
for _, probe := range probes {
|
||||
if err := checker.Add(probe); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return checker, nil
|
||||
}
|
||||
|
||||
// Add registers a named readiness probe. Names must be unique and checks must
|
||||
// be non-nil so configuration errors fail during startup.
|
||||
func (c *Checker) Add(probe Probe) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("health: nil checker")
|
||||
}
|
||||
probe.Name = strings.TrimSpace(probe.Name)
|
||||
if probe.Name == "" {
|
||||
return fmt.Errorf("health: probe name is required")
|
||||
}
|
||||
if probe.Check == nil {
|
||||
return fmt.Errorf("health: probe %q has no check", probe.Name)
|
||||
}
|
||||
if probe.Timeout < 0 {
|
||||
return fmt.Errorf("health: probe %q timeout must not be negative", probe.Name)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, current := range c.probes {
|
||||
if current.Name == probe.Name {
|
||||
return fmt.Errorf("health: duplicate probe %q", probe.Name)
|
||||
}
|
||||
}
|
||||
c.probes = append(c.probes, probe)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check runs every registered probe in registration order. A failed probe is
|
||||
// represented in the result; the other probes still run so operators see all
|
||||
// unavailable dependencies in one response.
|
||||
func (c *Checker) Check(ctx context.Context) Result {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
c.mu.RLock()
|
||||
probes := append([]Probe(nil), c.probes...)
|
||||
c.mu.RUnlock()
|
||||
|
||||
result := Result{Status: StatusReady, Checks: make([]CheckResult, 0, len(probes))}
|
||||
for _, probe := range probes {
|
||||
started := time.Now()
|
||||
probeContext := ctx
|
||||
cancel := func() {}
|
||||
if probe.Timeout > 0 {
|
||||
probeContext, cancel = context.WithTimeout(ctx, probe.Timeout)
|
||||
}
|
||||
err := probe.Check(probeContext)
|
||||
cancel()
|
||||
checkResult := CheckResult{
|
||||
Name: probe.Name,
|
||||
Status: StatusReady,
|
||||
DurationMs: time.Since(started).Seconds() * 1000,
|
||||
}
|
||||
if err != nil {
|
||||
result.Status = StatusNotReady
|
||||
checkResult.Status = StatusNotReady
|
||||
checkResult.Error = err.Error()
|
||||
}
|
||||
result.Checks = append(result.Checks, checkResult)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// LivenessHandler reports that the process is running. It does not execute
|
||||
// dependency probes and is safe to use for orchestrator liveness checks.
|
||||
func (c *Checker) LivenessHandler() httpx.Handler {
|
||||
return func(ctx *httpx.Context) error {
|
||||
return ctx.OK(map[string]string{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
// ReadinessHandler reports whether all registered dependencies are ready.
|
||||
// Failure uses HTTP 503 while preserving GoOmni's response envelope.
|
||||
func (c *Checker) ReadinessHandler() httpx.Handler {
|
||||
return func(ctx *httpx.Context) error {
|
||||
result := c.Check(ctx.Context())
|
||||
status := http.StatusOK
|
||||
code, message := "READY", "ready"
|
||||
if !result.Ready() {
|
||||
status = http.StatusServiceUnavailable
|
||||
code, message = "NOT_READY", "service is not ready"
|
||||
}
|
||||
return ctx.JSON(status, response.Response{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Data: result,
|
||||
RequestID: ctx.RequestID(),
|
||||
})
|
||||
}
|
||||
}
|
||||
73
health/health_test.go
Normal file
73
health/health_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||
)
|
||||
|
||||
func TestCheckerReadinessReportsAllProbes(t *testing.T) {
|
||||
checker, err := New(
|
||||
Probe{Name: "database", Check: func(context.Context) error { return nil }},
|
||||
Probe{Name: "cache", Check: func(context.Context) error { return errors.New("connection refused") }},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := omni.NewWithRouter(nethttp.New())
|
||||
app.GET("/live", checker.LivenessHandler())
|
||||
app.GET("/ready", checker.ReadinessHandler())
|
||||
|
||||
live := httptest.NewRecorder()
|
||||
app.ServeHTTP(live, httptest.NewRequest(http.MethodGet, "/live", nil))
|
||||
if live.Code != http.StatusOK {
|
||||
t.Fatalf("expected liveness 200, got %d", live.Code)
|
||||
}
|
||||
|
||||
ready := httptest.NewRecorder()
|
||||
app.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/ready", nil))
|
||||
if ready.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected readiness 503, got %d", ready.Code)
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
Data Result `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(ready.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Code != "NOT_READY" || body.Data.Status != StatusNotReady || len(body.Data.Checks) != 2 || body.Data.Checks[1].Error != "connection refused" {
|
||||
t.Fatalf("unexpected readiness response: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckerTimeoutAndRegistrationValidation(t *testing.T) {
|
||||
checker, err := New(Probe{
|
||||
Name: "slow",
|
||||
Timeout: 5 * time.Millisecond,
|
||||
Check: func(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := checker.Check(context.Background())
|
||||
if result.Ready() || result.Checks[0].Status != StatusNotReady {
|
||||
t.Fatalf("expected timeout to fail readiness: %+v", result)
|
||||
}
|
||||
|
||||
for _, probe := range []Probe{{Check: func(context.Context) error { return nil }}, {Name: "slow", Check: func(context.Context) error { return nil }}, {Name: "negative", Timeout: -time.Second, Check: func(context.Context) error { return nil }}} {
|
||||
if err := checker.Add(probe); err == nil {
|
||||
t.Fatalf("expected registration error for %+v", probe)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user