Appearance
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.gocmd/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/healthzhttp.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/checkscurl -i 会显示状态码和响应头。API 排查时,状态码、Content-Type 和响应体要对上。比如状态码是 200,但 Content-Type 是 text/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 | /healthz | 200 | 进程存活 |
GET | /api/checks | 200 | 检查完成且全部成功 |
GET | /api/checks | 503 | 检查完成但有目标失败 |
GET | /api/checks | 500 | 配置读取或服务内部错误 |
| 其他方法 | 任意已知路径 | 405 | 方法不允许 |
接口数量少时,标准库路由足够用。路由前缀、认证、中间件、OpenAPI 文档、复杂参数校验都出现后,再考虑 Gin、Fiber 或其他框架。框架解决的是工程组织和中间件问题,不替代 HTTP 状态码、JSON 响应和错误语义这些基础。