feat(go): add platform principles and health probes

This commit is contained in:
lingniu
2026-07-02 18:53:02 +08:00
parent 0bfd1191cb
commit 1dfd540700
8 changed files with 339 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ import (
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/gateway"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960"
@@ -39,6 +40,7 @@ func main() {
os.Exit(1)
}
defer closeResolver()
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-gateway", nil))
protocols := []gateway.TCPProtocol{
{

View File

@@ -14,6 +14,7 @@ import (
_ "github.com/taosdata/driver-go/v3/taosWS"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/history"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
)
@@ -34,6 +35,9 @@ func main() {
logger.Error("tdengine ping failed", "error", err)
os.Exit(1)
}
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-history-writer", []health.Check{
{Name: "tdengine", Check: db.PingContext},
}))
writer := history.NewWriter(db)
if cfg.EnsureSchema {

View File

@@ -17,6 +17,7 @@ import (
"github.com/segmentio/kafka-go"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
)
@@ -32,6 +33,14 @@ func main() {
os.Exit(1)
}
defer conn.Close()
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "nats-kafka-bridge", []health.Check{
{Name: "nats", Check: func(context.Context) error {
if conn.Status() != nats.CONNECTED {
return fmt.Errorf("nats status is %s", conn.Status().String())
}
return nil
}},
}))
js, err := conn.JetStream()
if err != nil {
logger.Error("nats jetstream init failed", "error", err)

View File

@@ -18,6 +18,7 @@ import (
_ "github.com/taosdata/driver-go/v3/taosWS"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/history"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
@@ -46,6 +47,9 @@ func main() {
})
mux := http.NewServeMux()
healthChecks := []health.Check{
{Name: "redis", Check: func(ctx context.Context) error { return client.Ping(ctx).Err() }},
}
mux.Handle("/openapi.json", realtime.NewOpenAPIHandler())
mux.Handle("/swagger-ui/", realtime.NewSwaggerUIHandler())
mux.Handle("/api/realtime/vehicles/", realtime.NewHandler(repository))
@@ -62,6 +66,7 @@ func main() {
logger.Error("stats mysql ping failed", "error", err)
os.Exit(1)
}
healthChecks = append(healthChecks, health.Check{Name: "mysql", Check: db.PingContext})
closeStats = func() { _ = db.Close() }
bindingTable := env("VEHICLE_IDENTITY_TABLE", "vehicle_identity_binding")
snapshotWriter := realtime.NewSnapshotWriterWithPlateResolver(db, realtime.NewBindingPlateResolver(db, bindingTable))
@@ -108,6 +113,7 @@ func main() {
logger.Error("history tdengine ping failed", "driver", driver, "error", err)
os.Exit(1)
}
healthChecks = append(healthChecks, health.Check{Name: "tdengine", Check: db.PingContext})
closeHistory = func() { _ = db.Close() }
database := env("TDENGINE_DATABASE", history.DefaultDatabase)
mux.Handle("/api/history/raw-frames", history.NewRawFrameHandler(history.NewRawFrameRepository(db, database)))
@@ -126,6 +132,9 @@ func main() {
logger.Warn("TDENGINE_DSN is empty; history query api disabled")
}
defer closeHistory()
healthHandler := health.NewHandler("vehicle-realtime-api", healthChecks)
mux.Handle("/healthz", healthHandler)
mux.Handle("/readyz", healthHandler)
server := &http.Server{
Addr: env("HTTP_ADDR", ":20210"),

View File

@@ -14,6 +14,7 @@ import (
"github.com/segmentio/kafka-go"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
)
@@ -34,6 +35,9 @@ func main() {
logger.Error("mysql ping failed", "error", err)
os.Exit(1)
}
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-stat-writer", []health.Check{
{Name: "mysql", Check: db.PingContext},
}))
writer := stats.NewWriter(db, cfg.Location)
if cfg.EnsureSchema {

View File

@@ -0,0 +1,132 @@
package health
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
)
type Check struct {
Name string
Check func(context.Context) error
}
type Handler struct {
service string
checks []Check
}
type Logger interface {
Info(string, ...any)
Error(string, ...any)
}
func NewHandler(service string, checks []Check) *Handler {
service = strings.TrimSpace(service)
if service == "" {
service = "vehicle-service"
}
return &Handler{service: service, checks: checks}
}
func NewMux(service string, checks []Check) *http.ServeMux {
mux := http.NewServeMux()
handler := NewHandler(service, checks)
mux.Handle("/healthz", handler)
mux.Handle("/readyz", handler)
return mux
}
func NewServer(addr string, service string, checks []Check) *http.Server {
addr = strings.TrimSpace(addr)
if addr == "" {
return nil
}
return &http.Server{
Addr: addr,
Handler: NewMux(service, checks),
ReadHeaderTimeout: 5 * time.Second,
}
}
func Start(ctx context.Context, logger Logger, server *http.Server) {
if server == nil {
return
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
go func() {
if logger != nil {
logger.Info("health server started", "addr", server.Addr)
}
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) && logger != nil {
logger.Error("health server failed", "error", err)
}
}()
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeHealth(w, http.StatusMethodNotAllowed, map[string]any{"error": "method not allowed"})
return
}
path := strings.Trim(r.URL.Path, "/")
if path != "healthz" && path != "readyz" {
writeHealth(w, http.StatusNotFound, map[string]any{"error": "route not found"})
return
}
if path == "healthz" {
writeHealth(w, http.StatusOK, map[string]any{
"service": h.service,
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
})
return
}
h.writeReadiness(w, r)
}
func (h *Handler) writeReadiness(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
status := "ok"
code := http.StatusOK
checks := make(map[string]any, len(h.checks))
for _, check := range h.checks {
name := strings.TrimSpace(check.Name)
if name == "" {
continue
}
if check.Check == nil {
checks[name] = map[string]any{"status": "ok"}
continue
}
if err := check.Check(ctx); err != nil {
status = "degraded"
code = http.StatusServiceUnavailable
checks[name] = map[string]any{"status": "error", "error": err.Error()}
continue
}
checks[name] = map[string]any{"status": "ok"}
}
writeHealth(w, code, map[string]any{
"service": h.service,
"status": status,
"time": time.Now().UTC().Format(time.RFC3339),
"checks": checks,
})
}
func writeHealth(w http.ResponseWriter, status int, payload map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}

View File

@@ -0,0 +1,98 @@
package health
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandlerReturnsHealthyWhenAllChecksPass(t *testing.T) {
handler := NewHandler("vehicle-realtime-api", []Check{
{Name: "redis", Check: func(context.Context) error { return nil }},
{Name: "mysql", Check: func(context.Context) error { return nil }},
})
request := httptest.NewRequest(http.MethodGet, "/readyz", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"service":"vehicle-realtime-api"`, `"status":"ok"`, `"redis":{"status":"ok"`, `"mysql":{"status":"ok"`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
}
func TestHandlerReturnsServiceUnavailableWhenCheckFails(t *testing.T) {
handler := NewHandler("vehicle-history-writer", []Check{
{Name: "tdengine", Check: func(context.Context) error { return errors.New("ping failed") }},
})
request := httptest.NewRequest(http.MethodGet, "/readyz", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"service":"vehicle-history-writer"`, `"status":"degraded"`, `"tdengine":`, `"status":"error"`, `"error":"ping failed"`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
}
func TestHandlerRejectsUnknownPath(t *testing.T) {
handler := NewHandler("vehicle-gateway", nil)
request := httptest.NewRequest(http.MethodGet, "/metrics", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
}
func TestNewMuxRegistersHealthAndReadinessRoutes(t *testing.T) {
mux := NewMux("vehicle-stat-writer", []Check{
{Name: "mysql", Check: func(context.Context) error { return nil }},
})
for _, path := range []string{"/healthz", "/readyz"} {
request := httptest.NewRequest(http.MethodGet, path, nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("%s status = %d body=%s", path, response.Code, response.Body.String())
}
}
}
func TestNewServerReturnsNilWhenAddressIsEmpty(t *testing.T) {
if server := NewServer("", "vehicle-gateway", nil); server != nil {
t.Fatalf("server = %#v, want nil", server)
}
}
func TestNewServerBuildsConfiguredHTTPServer(t *testing.T) {
server := NewServer(":20290", "vehicle-gateway", nil)
if server == nil {
t.Fatal("server is nil")
}
if server.Addr != ":20290" {
t.Fatalf("addr = %q", server.Addr)
}
if server.ReadHeaderTimeout == 0 {
t.Fatal("ReadHeaderTimeout should be configured")
}
}