Go 微服务
本页给出可直接运行的 Go 微服务模式:goroutine + channel 生产者消费者、小接口组合(io.Reader/io.Writer)、errors.Is/As/%w 链式包装、slog 结构化日志与 signal.Notify 优雅关闭;约定写进 SKILL,让 Agent 按惯用 Go 风格实现服务间通信。
SKILL 定义服务边界、接口与 mock 生成方式;禁止在库代码里 log.Fatal;优雅关闭通过 signal.Notify 与 http.Server.Shutdown(或 gRPC GracefulStop)实现。
客户端超时、重试与熔断策略写明默认值;protobuf 版本与 buf 工作流若存在,指向生成脚本与 go:generate 注释。
并发使用 errgroup 或 worker 池时的取消传播与资源上限(goroutine 泄漏防护)需在步骤中体现。
- 配置:viper/env 与结构体校验,十二因子与容器入口一致。
- 测试:表格驱动、
httptest、集成测试的 build tag 约定。 - 与「可观测性」技能联动:同一 trace / request id 贯穿日志与指标。
编写要点概览
将「传输层(HTTP/gRPC server)、用例层、适配器(DB/外部 RPC)」分层写清:对外可复用的稳定 API 放在 pkg(若确有跨仓需求),其余默认收进 internal,避免无意导出。
小接口组合:使用 io.Reader/io.Writer 组合而非大接口,降低测试替换成本。
// 小接口组合示例:io.Reader + io.Writer 组合
package storage
import "io"
// Reader 只暴露读能力,便于 mock
type Reader interface {
Get(ctx context.Context, key string) (io.ReadCloser, error)
}
// Writer 只暴露写能力
type Writer interface {
Put(ctx context.Context, key string, r io.Reader) error
}
// ReadWriter 按需组合,usecase 只接受它实际需要的接口
type ReadWriter interface {
Reader
Writer
}
// errors.Is/As/Wrap 正确用法
var ErrNotFound = errors.New("not found")
type StoreError struct {
Key string
Err error
}
func (e *StoreError) Error() string { return fmt.Sprintf("store: key=%s: %v", e.Key, e.Err) }
func (e *StoreError) Unwrap() error { return e.Err }
func GetItem(ctx context.Context, r Reader, key string) (io.ReadCloser, error) {
rc, err := r.Get(ctx, key)
if err != nil {
// %w 保留原始类型,调用方可 errors.Is(err, ErrNotFound)
return nil, fmt.Errorf("GetItem: %w", &StoreError{Key: key, Err: err})
}
return rc, nil
}
// 调用方判别
func handle(err error) {
var se *StoreError
if errors.As(err, &se) {
log.Printf("key=%s failed", se.Key)
}
if errors.Is(err, ErrNotFound) {
// 返回 HTTP 404
}
}
请求主流程(transport → usecase)
下列为单次入站调用在典型 Go 微服务中的顺序;具体中间件与拦截器以项目注册顺序为准。重点是:context 从入口注入,deadline 与取消向下传递到客户端与查询。
[ 入口:HTTP / gRPC listener ]
│
▼
┌─────────────┐ OTel/gRPC stats、鉴权、recover、可选限流
│ 中间件/拦截器 │
└─────────────┘
│
▼
┌─────────────┐ 解码请求;校验 DTO;注入依赖(接口实现)
│ handler │
└─────────────┘
│
▼
┌─────────────┐ 纯业务与编排;只接收 context + 领域类型
│ usecase │
└─────────────┘
│
▼
┌─────────────┐ DB / 缓存 / 下游 gRPC-HTTP;重试需可取消
│ adapters │
└─────────────┘
│
▼
[ 编码响应;记录指标与 span;返回 ]
优雅关闭:停止接受新连接 → 等待 in-flight 请求(带超时)→ 关闭 DB 池与后台 worker;与 Kubernetes preStop 休眠配合时写明文档。
// goroutine + channel 生产者消费者 示例(带 context 取消)
package worker
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
)
type Job struct{ ID int }
type Result struct{ ID int; Out string }
func Run(ctx context.Context, jobs []Job, concurrency int) ([]Result, error) {
jobCh := make(chan Job, len(jobs))
resCh := make(chan Result, len(jobs))
// 生产者:填入后立即关闭,不会泄漏
go func() {
defer close(jobCh)
for _, j := range jobs {
select {
case jobCh <- j:
case <-ctx.Done():
return
}
}
}()
// errgroup 管理 N 个消费者 goroutine,任一出错即取消其余
g, gctx := errgroup.WithContext(ctx)
for i := 0; i < concurrency; i++ {
g.Go(func() error {
for job := range jobCh {
select {
case <-gctx.Done():
return gctx.Err()
default:
}
// 模拟处理
resCh <- Result{ID: job.ID, Out: fmt.Sprintf("done-%d", job.ID)}
}
return nil
})
}
// 等待所有消费者完成,再关闭结果 channel
go func() { g.Wait(); close(resCh) }()
var results []Result
for r := range resCh {
results = append(results, r)
}
return results, g.Wait()
}
// 优雅关闭:signal.Notify + http.Server.Shutdown
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{Addr: ":8080", Handler: http.DefaultServeMux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
<-quit
slog.Info("shutting down", "timeout", "30s")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("shutdown error", "err", err)
}
slog.Info("server exited cleanly")
}
模块布局:internal 与 pkg
cmd/<binary>/main.go 只做组装:读配置、建 logger/tracer、wire 依赖并启动 server。internal/ 下按边界分包(如 api、service、store),包之间通过小接口解耦,便于单测替换。
internal:默认放置业务与实现细节;Go 编译器禁止其他模块导入,适合单体多二进制或多服务共仓。pkg:仅当确有「本仓库外可导入」的契约时再放置;否则易变成杂物间,优先保持internal为主。- 生成代码:
buf/protoc输出目录、mockgen或go:generate一行命令写进 SKILL,避免手工拷贝。
# 推荐目录布局
payments/
├── cmd/
│ └── server/
│ └── main.go # 只组装:配置 + logger + wire + server.Start
├── internal/
│ ├── api/
│ │ └── handler.go # HTTP/gRPC 传输层,解码/编码
│ ├── service/
│ │ └── payment.go # 用例层,只接收 context + 领域类型
│ ├── store/
│ │ └── postgres.go # DB 适配器,实现 service 依赖的接口
│ └── config/
│ └── config.go # viper/env 读取 + 结构体校验
├── pkg/
│ └── money/ # 仅限「跨仓真实复用」才放这里
│ └── amount.go
├── gen/ # buf/protoc 生成,不手动修改
│ └── proto/
├── go.mod
└── go.sum
# go.mod 示例
module github.com/acme/payments
go 1.22
require (
golang.org/x/sync v0.7.0
google.golang.org/grpc v1.63.2
)
# mockgen 生成命令(写进 SKILL 或 Makefile)
# go:generate go run go.uber.org/mock/mockgen -source=internal/service/payment.go -destination=internal/service/mock_payment.go
指标、日志与链路
SKILL 写明指标前缀(如 <service>_ 或 Prometheus 常量 Subsystem)、直方图 bucket 约定,以及 trace 与 log 中共享的 trace_id / span_id 字段名;与 OpenTelemetry Go SDK 的 resource(service.name、service.version)对齐。
- Prometheus:HTTP 常用
http_server_request_duration_seconds等语义,业务指标避免与高基数 label(如 user id)绑定。 - 结构化日志:使用
slog或 zap 等,字段键名与集中式查询(Loki/ELK)一致;错误日志带error与堆栈策略(是否仅在 debug 展开)。 - 延伸阅读:日志与分布式追踪、监控仪表盘。
// slog 结构化日志(Go 1.21+)— 与 trace 字段对齐
package main
import (
"context"
"log/slog"
"os"
"go.opentelemetry.io/otel/trace"
)
// newLogger 创建 JSON handler,输出到 stdout
func newLogger(level slog.Level) *slog.Logger {
opts := &slog.HandlerOptions{Level: level}
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}
// withTrace 从 context 注入 trace_id / span_id,与 OTel 对齐
func withTrace(ctx context.Context, logger *slog.Logger) *slog.Logger {
span := trace.SpanFromContext(ctx)
if !span.SpanContext().IsValid() {
return logger
}
sc := span.SpanContext()
return logger.With(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
}
// 使用示例:handler 中注入 trace 字段
func ProcessPayment(ctx context.Context, logger *slog.Logger, amount float64) error {
log := withTrace(ctx, logger)
log.Info("processing payment", slog.Float64("amount", amount))
if err := chargeCard(ctx, amount); err != nil {
// error 字段名与 Loki/ELK 标准查询对齐
log.Error("charge failed", slog.String("error", err.Error()))
return fmt.Errorf("ProcessPayment: %w", err)
}
log.Info("payment done", slog.Float64("amount", amount))
return nil
}
go.mod 模块路径预览
将草稿路径整理为适合 module 指令的一行:反斜杠转正斜杠、合并重复斜杠、去掉首尾空白与末尾斜杠。复杂路径(版本后缀、子模块)仍以团队 go.mod 与 GOPROXY 策略为准。
规范化结果
---
name: go-microservice
description: 按 Go 惯用法实现微服务边界、并发模式与可观测性
---
# 模块布局与接口
- cmd/<binary>/main.go 只组装依赖,不含业务逻辑
- internal/ 按边界分包(api / service / store)
- 接口最小化:只暴露调用方实际需要的方法集
- mockgen 命令写进 go:generate 或 Makefile
# 并发模式
- goroutine + channel 生产者消费者;errgroup 管理取消与汇聚
- 明确 goroutine 生命周期:谁负责关闭 channel
- worker 池限制并发上限,防止内存无限增长
# 错误处理
- %w 包装保留可判别类型;errors.Is / errors.As 在边界处理
- 自定义错误类型实现 Unwrap();handler 层映射 HTTP 状态
- 不在库代码 log.Fatal;错误向上传递到入口统一处理
# 可观测性
- slog JSON handler;字段名 trace_id / span_id 与 OTel 对齐
- 业务指标前缀:payments_;直方图 bucket 约定写入 SKILL
- 优雅关闭:signal.Notify SIGTERM → Shutdown(30s ctx) → 关池