Skip to content

Go服务开发

ops-checker 前面一直是命令行工具:启动后读取配置,执行检查,输出结果,然后退出。服务端形态多了一层 HTTP 入口,外部可以通过接口触发检查、读取健康状态、获取 JSON 结果。

Go 标准库的 net/http 已经能写一个轻量 REST API。Gin、Fiber 这类框架适合路由很多、中间件较多的服务;基础后端先用标准库能看清 HTTP 服务最核心的几件事:监听地址、路由、请求方法、JSON 编解码、状态码、错误返回和优雅退出。

一、服务形态

命令行工具和 HTTP 服务的差异:

形态输入输出生命周期
CLI参数、配置文件stdout、stderr、退出码执行完退出
HTTP 服务HTTP 请求HTTP 状态码、JSON 响应常驻运行

ops-checker-api 可以复用已有检查逻辑,只是在外面加一层 HTTP handler。检查函数仍然放在 internal/checker,HTTP 入口放在 internal/server

目录增加:

text
ops-checker/
├── cmd/
│   ├── ops-checker/
│   │   └── main.go
│   └── ops-checker-api/
│       └── main.go
└── internal/
    ├── checker/
    ├── config/
    └── server/
        ├── handler.go
        ├── response.go
        └── server.go

cmd/ops-checker 继续做 CLI,cmd/ops-checker-api 做 HTTP 服务。两个入口复用同一批内部包。

二、最小HTTP服务

新增文件:cmd/ops-checker-api/main.go

go
package main

import (
	"fmt"
	"net/http"
	"os"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
			return
		}

		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok\n"))
	})

	addr := ":8080"
	fmt.Println("listening on", addr)
	if err := http.ListenAndServe(addr, mux); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

运行:

bash
go run ./cmd/ops-checker-api
curl -i http://127.0.0.1:8080/healthz

http.NewServeMux() 是路由表,HandleFunc 把路径绑定到处理函数。ListenAndServe 会一直阻塞,直到监听失败或进程退出。端口被占用时,错误通常是 bind: address already in use

三、JSON响应

HTTP API 通常返回 JSON。响应写法收成小函数后,每个 handler 不用重复设置 header、编码 JSON、处理编码错误。

新增文件:internal/server/response.go

go
package server

import (
	"encoding/json"
	"net/http"
)

type ErrorResponse struct {
	Error string `json:"error"`
}

func WriteJSON(w http.ResponseWriter, status int, value any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)

	// 编码失败通常是程序内部对象不可序列化,这里不再引出新的错误响应结构。
	_ = json.NewEncoder(w).Encode(value)
}

func WriteError(w http.ResponseWriter, status int, message string) {
	WriteJSON(w, status, ErrorResponse{Error: message})
}

Content-Type 要在 WriteHeader 前设置。body 已经开始写出后,再改状态码通常来不及,状态码可能已经被默认写成 200

四、服务对象

handler 里需要用到配置路径、并发数和检查逻辑。把这些状态放进 Server 结构体,比散在全局变量里更清楚。

新增文件:internal/server/server.go

go
package server

import (
	"context"
	"net/http"

	"example.com/ops-checker/internal/checker"
	"example.com/ops-checker/internal/config"
)

type Server struct {
	configPath  string
	concurrency int
}

func New(configPath string, concurrency int) *Server {
	if concurrency <= 0 {
		concurrency = 1 // 参数传错时退回串行执行,避免并发数为 0 导致没有 worker。
	}

	return &Server{
		configPath:  configPath,
		concurrency: concurrency,
	}
}

func (s *Server) Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", s.healthz)
	mux.HandleFunc("/api/checks", s.runChecks)
	return mux
}

func (s *Server) loadTargets() ([]config.Target, error) {
	return config.LoadTargets(s.configPath)
}

func (s *Server) executeChecks(ctx context.Context, targets []config.Target) []checker.Result {
	return checker.Run(ctx, targets, s.concurrency)
}

这里假设 checker.Run 仍然是 HTTP 检查版本。如果已经接入 Kubernetes ClientSet,可以把 ClientSet 作为 Server 字段传入,再在 executeChecks 里调用带 ClientSet 的 Run。

五、健康检查

/healthz 只表示进程还活着,能接收请求。它不应该顺手跑完整巡检,否则探针本身会给下游服务增加压力。

新增文件:internal/server/handler.go

go
package server

import (
	"net/http"
)

func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
		return
	}

	WriteJSON(w, http.StatusOK, map[string]string{
		"status": "ok",
	})
}

健康检查常见分两种:

路径含义
/healthz进程存活,适合 livenessProbe
/readyz依赖就绪,适合 readinessProbe

基础版本先保留 /healthz。如果服务启动时要加载配置、连接 Kubernetes、连接数据库,再加 /readyz 表达“能不能接正式请求”。

六、触发检查

GET /api/checks 读取配置并执行检查,返回 JSON 数组。

继续修改文件:internal/server/handler.go

go
func (s *Server) runChecks(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
		return
	}

	targets, err := s.loadTargets()
	if err != nil {
		// 配置读取失败属于服务自身问题,不是某个目标检查失败。
		WriteError(w, http.StatusInternalServerError, err.Error())
		return
	}

	results := s.executeChecks(r.Context(), targets)
	status := http.StatusOK
	if checker.HasFailed(results) {
		status = http.StatusServiceUnavailable
	}

	WriteJSON(w, status, results)
}

返回 503 表示检查已经执行,但至少有一个目标失败。返回 500 表示服务自身没有完成检查,比如配置文件读取失败、JSON 解析失败。两种错误分开后,调用方能判断是下游目标异常,还是检查服务本身异常。

七、入口参数

cmd/ops-checker-api/main.go 从参数读取监听地址、配置文件和并发数。

替换文件:cmd/ops-checker-api/main.go

go
package main

import (
	"flag"
	"fmt"
	"net/http"
	"os"

	"example.com/ops-checker/internal/server"
)

func main() {
	addr := flag.String("addr", ":8080", "listen address")
	configPath := flag.String("config", "configs/targets.json", "target config file")
	concurrency := flag.Int("concurrency", 5, "max concurrent checks")
	flag.Parse()

	app := server.New(*configPath, *concurrency)

	fmt.Println("listening on", *addr)
	if err := http.ListenAndServe(*addr, app.Routes()); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

运行:

bash
go run ./cmd/ops-checker-api \
  -addr :8080 \
  -config configs/targets.json \
  -concurrency 5

本机验证:

bash
curl -i http://127.0.0.1:8080/healthz
curl -i http://127.0.0.1:8080/api/checks

curl -i 会显示状态码和响应头。API 排查时,状态码、Content-Type 和响应体要对上。比如状态码是 200,但 Content-Typetext/html,响应体是一段 Nginx 错误页,客户端按 JSON 解码失败就不是 Go handler 返回的问题;如果状态码是 422,响应体里指出 concurrency 不是数字,问题就在请求参数。

八、请求校验

如果接口允许通过查询参数覆盖并发数,就要把字符串参数转成数字,并处理非法输入。

示例:GET /api/checks?concurrency=10

新增函数:

go
func parseConcurrency(r *http.Request, fallback int) (int, error) {
	raw := r.URL.Query().Get("concurrency")
	if raw == "" {
		return fallback, nil
	}

	value, err := strconv.Atoi(raw)
	if err != nil {
		return 0, fmt.Errorf("invalid concurrency")
	}
	if value <= 0 || value > 100 {
		return 0, fmt.Errorf("concurrency out of range")
	}

	return value, nil
}

边界值要写清楚。并发数没有上限时,一个请求就可能触发大量下游探测,把检查服务和目标服务都打满。这里的 100 只是示例上限,实际值要按目标类型和运行环境调。

handler 里使用:

go
concurrency, err := parseConcurrency(r, s.concurrency)
if err != nil {
	WriteError(w, http.StatusBadRequest, err.Error())
	return
}

results := checker.Run(r.Context(), targets, concurrency)

参数错误返回 400。配置读取失败返回 500。目标检查失败返回 503。这些状态码分清楚后,调用方不需要靠解析错误文字判断问题类型。

九、优雅退出

ListenAndServe 直接运行时,进程收到停止信号会退出,但正在处理的请求可能被中断。服务部署到 systemd 或 Kubernetes 后,优雅退出很重要。

入口可以换成显式 http.Server

go
srv := &http.Server{
	Addr:    *addr,
	Handler: app.Routes(),
}

监听信号并关闭:

go
done := make(chan os.Signal, 1)
signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)

go func() {
	if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}()

<-done

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

if err := srv.Shutdown(ctx); err != nil {
	fmt.Fprintln(os.Stderr, "shutdown:", err)
	os.Exit(1)
}

Shutdown 会停止接收新连接,并等待正在处理的请求结束。超时时间到了还没结束,返回错误。Kubernetes 终止 Pod 时会发送 SIGTERM,这段逻辑能让服务有机会把正在执行的检查收尾。

十、接口约定

当前服务的接口面保持很小:

方法路径状态码含义
GET/healthz200进程存活
GET/api/checks200检查完成且全部成功
GET/api/checks503检查完成但有目标失败
GET/api/checks500配置读取或服务内部错误
其他方法任意已知路径405方法不允许

接口数量少时,标准库路由足够用。路由前缀、认证、中间件、OpenAPI 文档、复杂参数校验都出现后,再考虑 Gin、Fiber 或其他框架。框架解决的是工程组织和中间件问题,不替代 HTTP 状态码、JSON 响应和错误语义这些基础。