实战串讲(工程篇):“短链接服务”的工程化实践(中)
你好!我是Tony Bai。
上节课,我们有了合理的配置管理和高质量的结构化日志,为应用的稳定运行和可运维性打下了坚实的基础。这节课,我们将开始为应用装上“眼睛”和“耳朵”的另外两个重要部分:Metrics和Tracing,进一步增强其可观测性。
- 引入基础可观测性——Metrics:我们将使用
prometheus/client_golang库,在服务中暴露一些关键的性能指标(如HTTP请求计数和延迟),为后续的监控和告警打下基础。这部分内容关联可观测性这节课中关于Metrics的讲解。 - 引入基础可观测性——Tracing:我们将使用 OpenTelemetry Go SDK,为服务添加基本的分布式链路追踪能力,以便在未来更复杂的架构中追踪请求路径。为简化这两节课的实战环境,我们将使用标准输出(stdout)作为Trace的Exporter,让你能直观地在控制台看到追踪数据,而不必搭建复杂的外部追踪系统。这对应了可观测性这节课中关于Tracing的介绍。
接下来,我们一一来看。
引入基础可观测性——Metrics
配置和日志帮助我们理解应用的静态设置和离散事件,而Metrics(度量/指标)则为我们提供了量化应用宏观运行状态和性能趋势的能力。对于我们的“短链接服务”,我们关心诸如HTTP请求的总量、错误率、处理延迟等关键指标。
我们将使用Go社区的事实标准—— prometheus/client_golang 库,在应用中定义和暴露Prometheus格式的指标。这部分内容呼应了我们在可观测性这节课中关于Metrics的讨论,你也可以先复习相关内容,再继续学习。
使用 prometheus/client_golang 暴露应用指标
定义和注册核心Metrics( internal/metrics/metrics.go)
我们在这里定义一些对HTTP服务通用的基础指标,以及一些与“短链接服务”业务相关的特定指标。
// internal/metrics/metrics.go
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// ---- HTTP Server Metrics ----
// HTTPRequestsTotal 是一个CounterVec,用于记录HTTP请求的总数。
// 它按请求方法(method)、请求路径(path)和响应状态码(status_code)进行区分。
var HTTPRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: "shortlink", // 指标的命名空间,有助于组织和避免冲突
Subsystem: "http_server",
Name: "requests_total", // 完整指标名将是 shortlink_http_server_requests_total
Help: "Total number of HTTP requests processed by the shortlink service.",
},
[]string{"method", "path", "status_code"}, // 标签名列表
)
// HTTPRequestDurationSeconds 是一个HistogramVec,用于观察HTTP请求延迟的分布情况。
var HTTPRequestDurationSeconds = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "shortlink",
Subsystem: "http_server",
Name: "request_duration_seconds",
Help: "Histogram of HTTP request latencies for the shortlink service.",
Buckets: prometheus.DefBuckets, // prometheus.DefBuckets 是一组预定义的、通用的延迟桶
// 或者,你可以根据你的服务特性自定义桶的边界,例如:
// Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
},
[]string{"method", "path"}, // 按方法和路径区分
)
// ---- Application Specific Metrics (示例) ----
// ShortLinkCreationsTotal 记录成功创建的短链接总数
var ShortLinkCreationsTotal = promauto.NewCounter(
prometheus.CounterOpts{
Namespace: "shortlink",
Subsystem: "service",
Name: "creations_total",
Help: "Total number of short links successfully created.",
},
)
// ShortLinkRedirectsTotal 记录短链接重定向的总数,按状态(成功/未找到)区分
var ShortLinkRedirectsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: "shortlink",
Subsystem: "service",
Name: "redirects_total",
Help: "Total number of short link redirects, by status.",
},
[]string{"status"}, // "success", "not_found", "error"
)
// Init 初始化并注册所有必要的收集器。
// 这个函数应该在应用启动的早期被调用,例如在main.go中。
func Init() {
// (可选) 注册构建信息指标 (go_build_info)
prometheus.MustRegister(collectors.NewBuildInfoCollector())
// 我们自定义的指标(如HTTPRequestsTotal, ShortLinkCreationsTotal等)
// 因为使用了promauto包,它们在定义时已经自动注册到prometheus.DefaultRegisterer了,
// 所以这里不需要再次显式调用 prometheus.MustRegister() 来注册它们。
// 如果我们没有用promauto,而是用 prometheus.NewCounterVec() 等,则需要在这里注册。
// 例如:
// httpRequestsTotalPlain := prometheus.NewCounterVec(...)
// prometheus.MustRegister(httpRequestsTotalPlain)
}
首先,我们为指标名称添加了 Namespace(shortlink)和 Subsystem(http_server或service),这是Prometheus推荐的命名约定,有助于组织和避免指标名称冲突,最终的指标名会是 namespace_subsystem_name。
然后,我们使用了 promauto 包来创建和自动注册指标到Prometheus的默认注册表( prometheus.DefaultRegisterer)。这简化了代码,避免了忘记调用 prometheus.MustRegister()。
HTTPRequestsTotal 和 HTTPRequestDurationSeconds 是通用的HTTP服务指标。 ShortLinkCreationsTotal 和 ShortLinkRedirectsTotal 是与我们“短链接服务”业务逻辑相关的自定义指标示例。
最后,在 metrics.Init() 函数中,我们显式注册了 NewBuildInfoCollector 返回的Go的构建信息的指标。而 collectors.NewGoCollector() 和 collectors.NewProcessCollector() 无需显式注册,在导入 client_golang 包时,这些Go运行时自身和进程状态的宝贵指标信息(如goroutine数量、GC统计、内存使用、CPU时间、打开的文件描述符等)就会自动注册。
初始化,并在HTTP服务中暴露 /metrics 端点
在服务中使用metrics,我们需要对metrics的设施进行初始化,并暴露一个HTTP端点,通常是 /metrics,Prometheus服务器可以从这个端点抓取(scrape)上述定义的指标数据。
下面是在 internal/app/app.go 中实现上述需求的代码片段:
// internal/app/app.go (New函数的相关部分)
// ... (导入 appMetrics "github.com/your_org/shortlink/internal/metrics") ...
// ... (导入 "github.com/prometheus/client_golang/prometheus/promhttp") ...
// New 创建一个新的App实例,完成所有依赖的初始化和注入
func New(cfg *config.Config, logger *slog.Logger, appName, version string) (*App, error) {
app := &App{
cfg: cfg,
logger: logger.With("component", "appcore"), // App自身也可以有组件标识
appName: appName,
serviceVersion: version,
}
... ...
// 1b. 初始化 Metrics (Prometheus Go运行时等)
appMetrics.Init()
app.logger.Info("Prometheus Go runtime metrics collectors registered.")
... ...
// 3b. 注册诊断路由 (Metrics, pprof)
mux.Handle("/metrics", promhttp.Handler())
... ...
}
在上面的 New 函数中:
-
我们调用了
appMetrics.Init()来确保需要的采集指标等都被注册。 -
在创建
mux后,我们修改了根路径HandleFunc的逻辑,使其能够将对/metrics路径的请求专门交给promhttp.Handler()来处理。promhttp.Handler()会自动从prometheus.DefaultRegisterer中收集所有已注册的指标,并以Prometheus期望的文本格式输出。
在实际项目中,你可能会使用更专业的路由库(如 chi、 gin、 echo),它们通常提供更灵活的方式来注册Handler和中间件。或者,你可以创建两个不同的 http.ServeMux 实例:一个用于业务API(它会被各种中间件包裹),另一个用于内部的诊断API(如 /metrics、 /debug/pprof/,它通常不需要业务中间件)。然后在主 http.Server 中,根据请求的Host或Path前缀,将请求分发到不同的Mux。为了本节课的简洁性,我们暂时将所有路由都放在同一个 mux 上,并通过路径判断来区分。
要真正让 HTTPRequestsTotal 和 HTTPRequestDurationSeconds 这些我们自定义的HTTP指标动起来,我们还需要在处理每个业务层面的HTTP请求时去主动更新它们。在Go中,实现这种对所有(或部分)请求进行通用处理的最佳方式,就是通过HTTP中间件。
编写HTTP中间件记录/更新请求指标
HTTP中间件是一种函数或结构体,它包装了另一个http.Handler,在调用被包装的Handler之前或之后(或两者都)执行一些通用逻辑,例如日志记录、认证鉴权、Metrics收集、Tracing注入/提取等。这种模式也常被称为“装饰器模式”或“洋葱模型”。
我们将创建一个专门的Metrics中间件来实现服务在处理业务请求时对业务指标的更新。我们在internal/middleware/metrics.go中实现这个中间件。下面是代码片段:
// internal/middleware/metrics.go
package middleware
import (
"net/http"
"strconv"
"strings"
"time"
appMetrics "github.com/your_org/shortlink/internal/metrics" // 导入我们定义的metrics包
)
// responseWriter 是 http.ResponseWriter 的一个包装器,
// 主要目的是为了能够捕获到最终写入的HTTP状态码。
// 标准的 http.ResponseWriter 接口没有提供直接获取状态码的方法。
type responseWriter struct {
http.ResponseWriter
statusCode int
}
// newResponseWriter 创建一个新的 responseWriter 实例,
// 默认状态码为 http.StatusOK (200),除非后续被显式调用 WriteHeader 修改。
func newResponseWriter(w http.ResponseWriter) *responseWriter {
return &responseWriter{w, http.StatusOK}
}
// WriteHeader 捕获状态码,并调用原始 ResponseWriter 的 WriteHeader 方法。
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code // 捕获状态码
rw.ResponseWriter.WriteHeader(code)
}
// Metrics 是一个标准的Go HTTP中间件。
// 它接收一个 http.Handler (next),并返回一个新的 http.Handler。
// 这个新的Handler会先执行Metrics记录逻辑,再调用原始的next handler。
func Metrics(next http.Handler) http.Handler {
// 返回一个 http.HandlerFunc,这是一种将普通函数适配为 http.Handler 的便捷方式。
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// 跳过对诊断端点自身的指标记录,避免产生不必要的指标数据或潜在的循环。
if path == "/metrics" || strings.HasPrefix(path, "/debug/pprof") {
next.ServeHTTP(w, r) // 直接调用下一个处理器,不记录指标
return
}
startTime := time.Now() // 记录请求处理开始时间
// 创建我们包装的 responseWriter 来替换原始的 w,以便捕获状态码。
rw := newResponseWriter(w)
// 核心:调用链中的下一个处理器 (可能是另一个中间件,或者最终的业务handler)。
// 我们将包装后的 rw 传递下去。
next.ServeHTTP(rw, r)
// 在请求处理完毕后,执行指标更新逻辑
duration := time.Since(startTime).Seconds() // 计算请求总耗时,单位秒
statusCodeStr := strconv.Itoa(rw.statusCode) // 将捕获到的状态码转为字符串
// --- 路径规范化 (重要,但此处简化) ---
// 为了避免标签基数爆炸 (label cardinality explosion),
// 对于包含动态参数(如ID)的路径,我们需要将其规范化为一个模板。
// 例如,/users/123 和 /users/456 都应该被归类为 /users/{id}。
// 专业的路由库 (chi, gin, echo等) 通常能提供匹配到的路由模板。
// 对于标准库的http.ServeMux,我们需要自己实现这个逻辑。
// 为简化本实战示例,我们暂时使用了原始路径,但请务必在实际项目中解决此问题。
// if strings.HasPrefix(path, "/api/items/") { path = "/api/items/{id}" }
// 更新Prometheus指标
// 使用 WithLabelValues 获取带有特定标签组合的指标实例,然后进行操作。
appMetrics.HTTPRequestsTotal.WithLabelValues(r.Method, path, statusCodeStr).Inc()
appMetrics.HTTPRequestDurationSeconds.WithLabelValues(r.Method, path).Observe(duration)
})
}
然后,我们需要将这个中间件应用到HTTP请求处理链中。这个集成步骤通常在internal/app/app.go的New函数中完成,我们在那里构建和配置HTTP服务器。
// internal/app/app.go (New函数的相关部分)
// import "github.com/your_org/shortlink/internal/middleware" // 确保已导入
func New(cfg *config.Config, logger *slog.Logger, appName, version string) (*App, error) {
// ... (之前的初始化代码:可观测性、核心依赖、路由注册等) ...
// --- [App.New - 初始化阶段 4] ---
// 应用HTTP中间件 (顺序可能很重要)
var finalHandler http.Handler = mux // 从已注册好路由的mux开始
// 4a. 应用 Metrics 中间件
// 我们将mux(或其上层中间件处理后的handler)作为 `next` 参数传递给middleware.Metrics,
// 它返回一个新的、包装了Metrics记录逻辑的handler。
finalHandler = middleware.Metrics(finalHandler)
app.logger.Info("Applied HTTP Metrics middleware.")
// ... (后续可能还会应用Tracing和Logging中间件) ...
// --- [App.New - 初始化阶段 5] ---
// 创建并配置最终的HTTP服务器
app.httpServer = &http.Server{
Addr: ":" + app.cfg.Server.Port,
Handler: finalHandler, // 使用经过中间件包装过的最终handler
ReadTimeout: app.cfg.Server.ReadTimeout,
WriteTimeout: app.cfg.Server.WriteTimeout,
IdleTimeout: app.cfg.Server.IdleTimeout,
}
// ...
return app, nil
}
我们来解释下代码和其中的关键点。
-
中间件模式:
Metrics函数遵循了标准的Go HTTP中间件模式func(http.Handler) http.Handler。它接收一个next处理器,并返回一个新的处理器。这个新的处理器在内部执行自己的逻辑(记录开始时间、包装ResponseWriter),然后调用next.ServeHTTP将控制权传递给链中的下一个处理器,最后在next返回后执行自己的收尾逻辑(计算耗时、更新指标)。 -
捕获HTTP状态码:标准的
http.ResponseWriter接口在WriteHeader被调用后,无法再获取到已设置的状态码。为了在请求处理结束后能准确记录状态码,我们创建了一个responseWriter包装器。它内嵌了http.ResponseWriter(继承了其所有方法),但重写了WriteHeader方法,在调用原始的WriteHeader之前,先将状态码保存到自己的statusCode字段中。 -
指标更新逻辑:在
next.ServeHTTP执行完毕后,我们拥有了计算指标所需的所有信息。-
请求方法和路径:从
r.Method和r.URL.Path获取。 -
响应状态码:从我们包装的
rw.statusCode获取。 -
请求耗时:通过
time.Since(startTime)计算。 然后,我们调用之前在internal/metrics包中定义的Prometheus指标对象的.WithLabelValues(...)方法。这个方法会根据传入的标签值(如"GET"、"/api/links"、"201")返回一个具体的、带有一组特定标签的时间序列实例,然后我们再调用.Inc()(对Counter)或.Observe(duration)(对Histogram)来更新它的值。
-
-
中间件的应用:在
app.New()函数中,我们将创建好的mux(它包含了我们所有的业务和诊断路由)作为初始的http.Handler,然后用middleware.Metrics函数对其进行“包裹”,生成一个新的finalHandler。这个finalHandler现在就具备了自动记录请求指标的能力。如果未来还有其他中间件(如日志、追踪),它们会以类似的方式层层包裹。最终,这个被所有中间件包装过的finalHandler被设置为了http.Server的主处理器。
现在,当我们的“短链接服务”运行时,所有经过 finalHandler 的HTTP请求(除了被我们明确跳过的 /metrics 和 /debug/pprof)都会被Metrics中间件自动记录下来,我们可以随时通过访问 /metrics 端点来查看这些实时更新的、带有丰富标签的业务指标。
运行与验证:
-
确保所有代码已保存,并且
app.go中的finalHandler确实应用了middleware.Metrics。 -
在shortlink-service目录下运行
go run ./cmd/server/main.go。 -
发送一些HTTP请求到你的业务端点,例如:
-
curl -X POST -H "Content-Type: application/json" -d '{"long_url":"https://example.com"}' http://localhost:8081/api/links(假设端口是8081) -
curl http://localhost:8081/somecode -
curl http://localhost:8081/anothercode -
curl http://localhost:8081/notfoundpath
-
-
然后,在浏览器或用
curl访问Metrics端点:http://localhost:8081/metrics。 你应该能看到类似以下的输出(部分):
# HELP shortlink_http_request_duration_seconds Histogram of HTTP request latencies for the shortlink service.
# TYPE shortlink_http_request_duration_seconds histogram
shortlink_http_request_duration_seconds_bucket{method="POST",path="/api/links",le="0.005"} 0
shortlink_http_request_duration_seconds_bucket{method="POST",path="/api/links",le="0.01"} 1
...
shortlink_http_request_duration_seconds_sum{method="POST",path="/api/links"} 0.008
shortlink_http_request_duration_seconds_count{method="POST",path="/api/links"} 1
shortlink_http_request_duration_seconds_bucket{method="GET",path="/somecode",le="0.005"} 1
...
shortlink_http_request_duration_seconds_sum{method="GET",path="/somecode"} 0.003
shortlink_http_request_duration_seconds_count{method="GET",path="/somecode"} 1
# HELP shortlink_http_requests_total Total number of HTTP requests processed by the shortlink service.
# TYPE shortlink_http_requests_total counter
shortlink_http_requests_total{method="POST",path="/api/links",status_code="201"} 1
shortlink_http_requests_total{method="GET",path="/somecode",status_code="302"} 1
shortlink_http_requests_total{method="GET",path="/anothercode",status_code="302"} 1
shortlink_http_requests_total{method="GET",path="/notfoundpath",status_code="404"} 1
# HELP go_goroutines Number of goroutines that currently exist.
# TYPE go_goroutines gauge
go_goroutines 7
... (其他Go运行时指标) ...
这表明我们的自定义HTTP指标和Go运行时指标都已成功暴露。Prometheus服务器现在就可以配置来抓取这些数据了。
通过以上步骤,我们为“短链接服务”集成了基础的Metrics能力,使其能够量化关键的HTTP服务质量。这为后续的监控、告警和性能分析打下了坚实的数据基础。
在Metrics为我们提供了宏观状态的“仪表盘”之后,我们还需要一种方法来理解单个请求在系统内部(甚至跨多个服务)的完整“旅行轨迹”和耗时细节,这就是分布式追踪(Tracing)的用武之地。
引入基础可观测性——Tracing
当一个请求在我们的“短链接服务”内部流转,从接收HTTP请求,到调用业务逻辑服务,再到与存储层交互时,我们希望能够清晰地看到这个流程中每一步的耗时和上下文。我们将使用OpenTelemetry(OTel)Go SDK来实现这种应用内的链路追踪。
为了简化本实战串讲的环境搭建,我们将使用OTel的 stdouttrace Exporter,它会将追踪数据以人类可读的格式直接打印到标准输出。 这使得我们无需搭建和配置像Jaeger、Tempo或OpenTelemetry Collector这样的外部追踪后端,就能直观地在控制台看到生成的Trace Span信息。在真实的生产环境中,你会将其替换为OTLP Exporter,将数据发送到专业的追踪系统。
这部分内容呼应了可观测性这节中关于Tracing的详细讨论。
使用OpenTelemetry Go SDK实现基本链路追踪
初始化TracerProvider( internal/tracing/tracer.go)
我们将创建一个函数来配置和创建全局的 TracerProvider。这个Provider是所有追踪活动的源头,负责创建Tracer实例、管理采样策略和将完成的Span数据发送给Exporter。
// internal/tracing/tracer.go
package tracing
import (
"fmt"
"log/slog" // 使用slog记录初始化日志
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
// InitTracerProvider initializes an OpenTelemetry TracerProvider with a stdout exporter.
// This is suitable for demos and local development to see traces in the console.
func InitTracerProvider(serviceName, serviceVersion string, enabled bool, sampleRatio float64) (*sdktrace.TracerProvider, error) {
if !enabled {
slog.Info("Distributed tracing is disabled by configuration.", slog.String("service_name", serviceName))
// 如果禁用,返回nil,main函数中将不会设置全局TracerProvider,
// otel.Tracer() 将返回一个NoOpTracer,不会产生实际的trace数据。
return nil, nil
}
slog.Info("Initializing TracerProvider...",
slog.String("service_name", serviceName),
slog.String("exporter_type", "stdout"),
slog.Float64("sample_ratio", sampleRatio),
)
// 1. 创建一个Exporter,这里使用标准输出 (stdouttrace)
// WithPrettyPrint 使控制台输出的trace信息更易读。
exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
if err != nil {
return nil, fmt.Errorf("tracing: failed to create stdout trace exporter: %w", err)
}
slog.Debug("Stdout trace exporter initialized.")
// 2. 定义资源 (Resource),包含服务名、版本等通用属性
// 这些属性会附加到所有由此Provider产生的Span上。
res, err := resource.Merge(
resource.Default(), // 包含默认属性如 telemetry.sdk.language, .name, .version
resource.NewWithAttributes(
semconv.SchemaURL, // OTel语义约定schema URL
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
// 可以添加更多环境或部署相关的属性,例如:
// attribute.String("deployment.environment", "development"),
),
)
if err != nil {
return nil, fmt.Errorf("tracing: failed to create OTel resource: %w", err)
}
slog.Debug("OTel resource defined.", slog.Any("resource_attributes", res.Attributes()))
// 3. 配置采样器 (Sampler)
var sampler sdktrace.Sampler
if sampleRatio >= 1.0 {
sampler = sdktrace.AlwaysSample() // 采样所有
} else if sampleRatio <= 0.0 {
sampler = sdktrace.NeverSample() // 不采样
} else {
// 根据给定的比例进行采样
sampler = sdktrace.TraceIDRatioBased(sampleRatio)
}
// ParentBased确保如果上游服务(在分布式场景下)已经做出了采样决策,则遵循该决策;
// 如果是根Trace(没有父Span),则使用我们上面配置的本地采样器(sampler)。
finalSampler := sdktrace.ParentBased(sampler)
slog.Debug("OTel sampler configured.", slog.Float64("effective_sample_ratio", sampleRatio))
// 4. 创建TracerProvider,并配置SpanProcessor和Resource
// NewBatchSpanProcessor将span批量异步导出,性能更好,是生产推荐(即使是对stdout exporter)。
// NewSimpleSpanProcessor会同步导出每个span,仅用于非常简单的测试或调试。
bsp := sdktrace.NewBatchSpanProcessor(exporter) // (可选) 配置批处理器参数,例如批处理超时、队列大小等
// sdktrace.WithBatchTimeout(5*time.Second),
// sdktrace.WithMaxQueueSize(2048),
tp := sdktrace.NewTracerProvider(
sdktrace.WithSampler(finalSampler),
sdktrace.WithResource(res),
sdktrace.WithSpanProcessor(bsp), // 注册BatchSpanProcessor
)
slog.Debug("OTel TracerProvider created.")
// 5. 设置为全局TracerProvider和全局TextMapPropagator
// 这使得我们可以在应用的其他地方通过otel.Tracer("instrumentation-name")获取tracer实例,
// 并通过otel.GetTextMapPropagator()进行上下文传播(主要用于分布式场景,但在单体内规范使用也有好处)。
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(
propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, // W3C Trace Context propagator (这是HTTP Headers的标准)
propagation.Baggage{}, // W3C Baggage propagator (可选,用于传递业务数据)
),
)
slog.Info("OpenTelemetry TracerProvider initialized and set globally with stdout exporter.", slog.String("service_name", serviceName))
return tp, nil
}
代码说明:
-
InitTracerProvider函数负责创建和配置一个sdktrace.TracerProvider。 -
Exporter:为了本实战的简单性和快速验证,我们使用了
stdouttrace.New(stdouttrace.WithPrettyPrint())。它会将所有收集到的Trace Span以人类可读的JSON格式直接打印到应用的标准输出。这让我们无需搭建任何外部追踪系统,就能在控制台直观地看到追踪数据。在生产环境中,你会将这个Exporter替换为例如otlptracegrpc.New(),以便将数据通过OTLP协议发送到OpenTelemetry Collector或兼容的后端服务(如Jaeger、Tempo)。 -
Resource:通过
resource.NewWithAttributes,我们为所有从这个Provider产生的Span都附加了一些通用的元数据,如服务名(semconv.ServiceNameKey)和服务版本。使用semconv(Semantic Conventions)中预定义的键名是OpenTelemetry的最佳实践,有助于在不同的可观测性工具间保持数据的一致性。 -
Sampler(采样器):我们配置了采样策略。
TraceIDRatioBased可以按比例采样(例如,只记录50%的请求追踪),而AlwaysSample则会记录所有请求的追踪(这在开发和调试时非常有用)。ParentBased包装器确保我们的服务会遵循上游服务(在分布式场景下)传递过来的采样决策,保持整个Trace的采样一致性。 -
SpanProcessor(Span处理器):我们使用了
sdktrace.NewBatchSpanProcessor。这是一个生产推荐的处理器,它会批量、异步地将已完成的Span发送给Exporter,相比于同步发送每个Span的SimpleSpanProcessor,性能更好,对应用主逻辑的影响更小。 -
全局设置:通过
otel.SetTracerProvider(tp)和otel.SetTextMapPropagator(...),我们将创建好的TracerProvider和上下文传播器(我们使用了标准的W3C Trace Context和Baggage)设置为全局默认实例。这使得我们可以在应用的其他任何地方,通过otel.Tracer("instrumentation-name")方便地获取到一个Tracer实例,并由otelhttp等库自动使用全局的传播器。
在 internal/app/app.go 中初始化并确保关闭TracerProvider
我们需要在应用启动的早期调用 InitTracerProvider,并在应用优雅退出时调用 TracerProvider.Shutdown() 以确保所有缓冲的Span都被正确导出(对于stdout exporter,主要是确保日志被刷出)。和metrics一样,这些工作也都在 internal/app/app.go 的New函数和Run方法中实现:
// internal/app/app.go
func New(cfg *config.Config, logger *slog.Logger, appName, version string) (*App, error) {
... ...
// --- [App.New - 初始化阶段 1] ---
// 初始化可观测性组件 (Tracing & Metrics)
if app.cfg.Tracing.Enabled {
var errInitTracer error
// 1a. 初始化 Tracing (OpenTelemetry)
app.tracerProvider, errInitTracer = appTracing.InitTracerProvider(
app.appName,
app.serviceVersion,
app.cfg.Tracing.Enabled,
app.cfg.Tracing.SampleRatio,
)
if errInitTracer != nil {
app.logger.Error("Failed to initialize TracerProvider", slog.Any("error", errInitTracer))
return nil, fmt.Errorf("app.New: failed to init tracer provider: %w", errInitTracer)
}
}
... ...
// 4b. 应用 Tracing 中间件
if app.tracerProvider != nil {
finalHandler = otelhttp.NewHandler(finalHandler, fmt.Sprintf("%s.http.server", app.appName),
otelhttp.WithMessageEvents(otelhttp.ReadEvents, otelhttp.WriteEvents),
)
app.logger.Info("Applied OpenTelemetry HTTP Tracing middleware.")
}
}
func (a *App) Run() error {
a.logger.Info("Starting application run cycle...",
slog.String("appName", a.appName),
slog.String("version", a.serviceVersion),
)
... ...
if a.tracerProvider != nil {
tpShutdownCtx, tpCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer tpCancel()
a.logger.Info("Attempting to shut down TracerProvider...")
if err := a.tracerProvider.Shutdown(tpShutdownCtx); err != nil {
a.logger.Error("Error shutting down TracerProvider", slog.Any("error", err))
} else {
a.logger.Info("TracerProvider shut down successfully.")
}
}
a.logger.Info("Application has shut down completely.")
return nil // 优雅关闭完成,返回nil
}
关键改动说明:
- 在
app.New()中初始化:-
我们在应用的构造函数
New()中,紧随日志系统初始化之后,调用了appTracing.InitTracerProvider()。这样可以确保在后续所有核心组件(Store, Service, Handler等)初始化之前,追踪系统已经就绪。 -
根据配置文件中的
tracing.enabled标志,我们决定是否真正启用追踪。如果禁用,tracerProvider字段将保持为nil。
-
- 在
app.Run()中优雅关闭:-
在
Run()方法的优雅退出逻辑中,我们在关闭HTTP服务器之后,明确地调用了a.tracerProvider.Shutdown(tpShutdownCtx)。 -
这是至关重要的一步! 因为我们使用了
BatchSpanProcessor,它会在内存中缓冲Span。调用Shutdown()会强制处理器将所有缓冲的Span导出。如果忘记调用Shutdown,在应用快速退出时可能会丢失最近产生的追踪数据。我们还为这个关闭操作设置了一个独立的短超时(5秒)。
-
- 应用
otelhttp中间件:-
在
New()函数配置HTTP处理链时,我们加入了go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp包提供的otelhttp.NewHandler中间件。 -
这个中间件会自动为每个进入的HTTP请求:
-
尝试从入站请求头中提取Trace Context(如果上游服务传递了追踪信息)。
-
如果不存在入站Trace Context,则开始一个新的Trace。
-
创建一个代表整个HTTP请求处理过程的根Span(Server Span)。
-
将这个Span的信息注入到
request.Context()中。 -
在请求处理结束时,自动结束这个Span,并记录HTTP相关的标准属性(如状态码、方法、路径等)。
-
-
通过使用这个中间件,我们极大地简化了对HTTP请求入口的追踪插桩工作。
-
在Service层或更深层逻辑中手动创建子Span
上面的 otelhttp.NewHandler 中间件为我们处理了HTTP请求的入口Span/根Span。但要获得更深入的洞察,我们需要在应用内部的关键逻辑单元(如Service层的方法)中手动创建子Span(Child Spans),以形成更详细的调用链。例如 ShortenerService 中的方法调用了 Store 层的方法,或者执行了一些其他有意义的独立操作单元,我们应该在这些地方手动创建子Span(Child Spans),以获得更细致的追踪信息和耗时分解。下面以shortener_service.go中的处理逻辑为例,看看如何手工创建子Span:
// internal/service/shortener_service.go
package service
type shortenerServiceImpl struct {
store store.Store // 存储接口的依赖
logger *slog.Logger // 日志记录器
tracer trace.Tracer // OpenTelemetry Tracer,用于手动创建子Span
}
// NewShortenerService 创建一个新的ShortenerService实例
func NewShortenerService(s store.Store, logger *slog.Logger) ShortenerService {
return &shortenerServiceImpl{
store: s,
logger: logger,
// 获取一个Tracer实例。Tracer的名称通常使用其所属的库或模块的导入路径。
tracer: otel.Tracer("github.com/your_org/shortlink/internal/service"),
}
}
// generateSecureRandomCode 生成一个指定长度的、URL安全的随机字符串作为短码
func (s *shortenerServiceImpl) generateSecureRandomCode(ctx context.Context, length int) (string, error) {
// 为这个内部操作创建一个子Span
_, span := s.tracer.Start(ctx, "service.generateSecureRandomCode")
defer span.End()
randomBytes := make([]byte, length)
if _, err := rand.Read(randomBytes); err != nil {
s.logger.ErrorContext(ctx, "Failed to read random bytes for short code generation", slog.Any("error", err))
span.RecordError(err)
span.SetStatus(codes.Error, "rand.Read failed")
return "", fmt.Errorf("service: failed to generate random bytes: %w", err)
}
// 使用URLEncoding可以避免/和+字符,使其更适合用在URL路径中
// 并取其前length个字符
shortCode := base64.URLEncoding.EncodeToString(randomBytes)
shortCode = strings.ReplaceAll(shortCode, "_", "A") // 替换下划线
shortCode = strings.ReplaceAll(shortCode, "-", "B") // 替换连字符
if len(shortCode) < length { // 理论上不太可能,除非length非常小
err := fmt.Errorf("generated base64 string too short")
span.RecordError(err)
span.SetStatus(codes.Error, "base64 string too short")
return "", err
}
finalCode := shortCode[:length]
span.SetAttributes(attribute.String("generated_code", finalCode))
return finalCode, nil
}
// CreateShortLink 为给定的长URL创建一个短链接
func (s *shortenerServiceImpl) CreateShortLink(ctx context.Context, longURL string, userID string, originalURL string, expireAt time.Time) (string, error) {
// 1. 从传入的ctx启动一个新的子Span,用于追踪这个方法的执行
ctx, span := s.tracer.Start(ctx, "ShortenerService.CreateShortLink", trace.WithAttributes(
attribute.String("long_url", longURL),
attribute.String("user_id", userID),
attribute.String("original_url", originalURL),
))
defer span.End() // 确保Span在函数退出时被结束
s.logger.DebugContext(ctx, "Service: Attempting to create short link.",
slog.String("longURL", longURL), slog.String("userID", userID))
// 校验输入
if strings.TrimSpace(longURL) == "" {
err := errors.New("long URL cannot be empty")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.WarnContext(ctx, "Validation failed for CreateShortLink: long URL empty")
return "", err
}
// (可以添加更多对longURL格式的校验)
// 如果originalURL为空,则使用longURL
if originalURL == "" {
originalURL = longURL
}
// 如果expireAt是零值,设置一个默认的过期时间
if expireAt.IsZero() {
expireAt = time.Now().Add(time.Hour * defaultExpiryHours)
span.SetAttributes(attribute.String("default_expiry_applied", expireAt.Format(time.RFC3339)))
}
span.SetAttributes(attribute.String("expire_at", expireAt.Format(time.RFC3339)))
var shortCode string
var err error
for attempt := 0; attempt < maxGenerationAttempts; attempt++ {
// 为生成和检查短码的每次尝试创建一个更细粒度的子Span
attemptCtx, attemptSpan := s.tracer.Start(ctx, fmt.Sprintf("ShortenerService.CreateShortLink.Attempt%d", attempt+1))
shortCode, err = s.generateSecureRandomCode(attemptCtx, defaultCodeLength) // 使用内部方法
if err != nil {
// 这个错误通常是内部生成随机串失败,比较严重,直接返回
s.logger.ErrorContext(attemptCtx, "Service: Failed to generate random string for short code.", slog.Any("error", err))
attemptSpan.RecordError(err)
attemptSpan.SetStatus(codes.Error, "short code internal generation failed")
attemptSpan.End()
span.RecordError(err) // 在父Span也记录这个严重错误
span.SetStatus(codes.Error, "short code internal generation failed")
return "", fmt.Errorf("service: internal error generating short code: %w", err)
}
attemptSpan.SetAttributes(attribute.String("generated_code_attempt", shortCode))
s.logger.DebugContext(attemptCtx, "Service: Generated short code attempt.", slog.String("short_code", shortCode))
// 检查短码是否已存在 (这个store调用也应该被追踪)
// 我们将在Store的实现中添加Tracing (如果需要更细粒度)
// 这里,我们假设FindByShortCode是Store接口的一部分
existingEntry, findErr := s.store.FindByShortCode(attemptCtx, shortCode) // 将attemptCtx传递下去
if errors.Is(findErr, store.ErrNotFound) {
s.logger.InfoContext(attemptCtx, "Service: Generated short code is unique.", slog.String("short_code", shortCode))
entryToSave := &store.LinkEntry{
ShortCode: shortCode,
LongURL: longURL,
OriginalURL: originalURL,
UserID: userID,
CreatedAt: time.Now(), // 在Service层设置创建时间
ExpireAt: expireAt,
}
saveErr := s.store.Save(attemptCtx, entryToSave)
if saveErr == nil {
s.logger.InfoContext(ctx, "Service: Successfully created and saved short link.",
slog.String("short_code", shortCode),
slog.String("long_url", longURL),
)
span.SetAttributes(attribute.String("final_short_code", shortCode))
span.SetStatus(codes.Ok, "short link created")
attemptSpan.SetStatus(codes.Ok, "short code unique and saved")
attemptSpan.End()
return shortCode, nil
}
s.logger.WarnContext(attemptCtx, "Service: Failed to save unique short code, retrying if possible.",
slog.String("short_code", shortCode), slog.Any("save_error", saveErr))
err = saveErr
attemptSpan.RecordError(saveErr)
attemptSpan.SetStatus(codes.Error, "failed to save short code")
} else if findErr != nil {
s.logger.ErrorContext(attemptCtx, "Service: Store error checking short code existence.",
slog.String("short_code", shortCode), slog.Any("find_error", findErr))
err = findErr
attemptSpan.RecordError(findErr)
attemptSpan.SetStatus(codes.Error, "store error checking short code")
} else {
// findErr is nil and existingEntry is not nil, means shortCode already exists
s.logger.DebugContext(attemptCtx, "Service: Short code collision.", slog.String("short_code", shortCode), slog.String("existing_long_url", existingEntry.LongURL))
err = fmt.Errorf("short code %s collision (attempt %d)", shortCode, attempt+1)
attemptSpan.SetAttributes(attribute.Bool("collision", true))
attemptSpan.SetStatus(codes.Error, "short code collision")
}
attemptSpan.End()
// 如果是存储错误(而不是未找到或冲突),则不应继续重试
if !errors.Is(findErr, store.ErrNotFound) && findErr != nil {
span.RecordError(err) // 将store错误记录到父span
span.SetStatus(codes.Error, "failed due to store error during creation")
return "", fmt.Errorf("service: failed to create short link after store error: %w", err)
}
}
// 如果循环结束仍未成功 (通常是多次冲突)
finalErr := ErrIDGenerationFailed
s.logger.ErrorContext(ctx, "Service: Failed to generate a unique short code after multiple attempts.",
slog.Int("max_attempts", maxGenerationAttempts),
)
span.RecordError(finalErr)
span.SetStatus(codes.Error, finalErr.Error())
return "", finalErr
}
// GetOriginalURL 根据短码查找原始长链接
func (s *shortenerServiceImpl) GetOriginalURL(ctx context.Context, shortCode string) (*store.LinkEntry, error) {
ctx, span := s.tracer.Start(ctx, "ShortenerService.GetOriginalURL", trace.WithAttributes(
attribute.String("short_code", shortCode),
))
defer span.End()
s.logger.InfoContext(ctx, "Service: Attempting to retrieve original URL for short code.", slog.String("short_code", shortCode))
if strings.TrimSpace(shortCode) == "" {
err := errors.New("short code cannot be empty")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
s.logger.WarnContext(ctx, "Validation failed for GetOriginalURL: short code empty")
return nil, err
}
entry, err := s.store.FindByShortCode(ctx, shortCode)
if err != nil {
s.logger.WarnContext(ctx, "Service: Failed to find original URL for short code.",
slog.String("short_code", shortCode),
slog.Any("error", err), // 这个err可能是store.ErrNotFound或者其他DB错误
)
span.RecordError(err)
if errors.Is(err, store.ErrNotFound) {
span.SetStatus(codes.Error, "short code not found") // 更具体的错误状态
} else {
span.SetStatus(codes.Error, "store error retrieving short code")
}
return nil, err
}
// 检查是否过期
if !entry.ExpireAt.IsZero() && time.Now().After(entry.ExpireAt) {
s.logger.InfoContext(ctx, "Service: Short code found but has expired.",
slog.String("short_code", shortCode),
slog.Time("expire_at", entry.ExpireAt),
)
// (可选) 在这里可以从存储中删除过期的条目 (异步或同步)
// s.store.Delete(ctx, shortCode)
span.SetAttributes(attribute.Bool("expired", true))
span.SetStatus(codes.Error, "short code expired")
return nil, store.ErrNotFound // 对外表现为未找到
}
s.logger.InfoContext(ctx, "Service: Successfully retrieved original URL.",
slog.String("short_code", shortCode),
slog.String("original_url", entry.OriginalURL), // 假设LinkEntry有OriginalURL
)
span.SetAttributes(attribute.String("retrieved_original_url", entry.OriginalURL))
span.SetStatus(codes.Ok, "original URL retrieved")
// 可以在这里异步增加访问计数 (如果Store.IncrementVisitCount是异步安全的,或者用另一个goroutine)
// go s.store.IncrementVisitCount(context.Background(), shortCode) // 简单示例,实际要考虑错误处理
// 或者,如果IncrementVisitCount是快速的,也可以同步调用
// visitCtx, visitSpan := s.tracer.Start(ctx, "ShortenerService.IncrementVisitCount")
// if errVisit := s.store.IncrementVisitCount(visitCtx, shortCode); errVisit != nil {
// s.logger.ErrorContext(visitCtx, "Failed to increment visit count", slog.String("short_code", shortCode), slog.Any("error", errVisit))
// visitSpan.RecordError(errVisit)
// visitSpan.SetStatus(codes.Error, "failed to increment visit count")
// }
// visitSpan.End()
return entry, nil
}
代码说明:
-
获取Tracer实例:在
NewShortenerService构造函数中,我们通过otel.Tracer("...")获取了一个专属于service包的Tracer实例。Tracer的名称通常使用其所属的库或模块的导入路径,这是一个好习惯,有助于在追踪后端区分Span的来源。 -
创建子Span:在
CreateShortLink和GetOriginalURL这两个核心业务方法的开始,我们都调用了s.tracer.Start(ctx, "span-operation-name", ...)。-
关键:我们将从上层(Handler层,经由
otelhttp中间件)传递过来的context.Context作为第一个参数传入。 OTel SDK会自动检测到这个ctx中已存在的父Span(由otelhttp创建),并使新创建的Span成为其子Span,从而将它们链接到同一个Trace中。 -
Start方法会返回一个新的、嵌入了新创建的子Span信息的context.Context,以及trace.Span对象本身。 后续在这个方法内部的所有操作,特别是调用其他需要被追踪的函数(如s.store的方法), 都应该使用这个新的ctx,以确保追踪上下文能够正确地向下传播。
-
-
确保Span结束(
defer span.End()):这是手动创建Span时最容易忘记但又至关重要的一步。defer span.End()确保了无论函数是正常返回还是因panic退出,Span都会被标记为结束,其结束时间和持续时长才会被正确记录。 -
丰富Span内容:
-
属性(Attributes):我们使用
span.SetAttributes(...)为Span添加了有意义的业务或技术上下文,例如输入的longURL、生成的shortCode、userID等。这些属性在后续的Trace查询和分析中非常有用。 -
事件(Events):虽然本示例中没有直接使用,但你可以通过
span.AddEvent("event-name", ...)在Span的生命周期内记录一些离散的、有时间戳的事件(例如,“Cache Hit”或“Retrying…”)。 -
错误与状态(Errors & Status):当操作发生错误时,我们使用
span.RecordError(err)来记录具体的错误信息,并使用span.SetStatus(codes.Error, err.Error())将Span的状态明确标记为错误。对于成功的操作,用span.SetStatus(codes.Ok, ...)标记。这在追踪后端的可视化界面中通常会以不同的颜色或图标醒目地展示出来。
-
-
细粒度追踪:在
CreateShortLink的重试循环中,我们甚至为每一次生成和检查短码的尝试创建了更细粒度的子Span(ShortenerService.CreateShortLink.AttemptN),并在其中模拟了对Store层方法的调用(也创建了模拟的Store Span)。这使得我们在分析Trace时,能够清晰地看到重试的次数、每次尝试生成的代码,以及每次模拟的数据库查询耗时,对于诊断复杂的重试逻辑或性能问题非常有帮助。
运行与观察(单体应用,输出到stdout):
-
确保你的
main.go中已经正确初始化了TracerProvider(使用stdouttraceExporter)并应用了otelhttp.NewHandler中间件。 -
确保
shortener_service.go中的NewShortenerService和其方法已按上述方式插桩。 -
运行
go run ./cmd/server/main.go。 -
通过浏览器或
curl访问你的API端点,例如:-
curl -X POST -H "Content-Type: application/json" -d '{"long_url":"https://example.com/very/long/url"}' http://localhost:8081/api/links(假设端口是8081) -
然后用返回的shortCode访问
http://localhost:8081/{shortCode}
-
-
观察控制台的输出。你应该能看到类似这样的(经过美化的)Trace Span信息被打印出来:
{
"Name": "shortlink.http.server.requests", // 来自otelhttp中间件的根Span (或你指定的名字)
"SpanContext": { ... "TraceID": "TRACE_ID_A", "SpanID": "SPAN_ID_ROOT", ... },
"Parent": {}, ...
}
{
"Name": "ShortenerService.CreateShortLink", // service层的子Span
"SpanContext": { ... "TraceID": "TRACE_ID_A", "SpanID": "SPAN_ID_SERVICE_CREATE", ... },
"Parent": {"TraceID": "TRACE_ID_A", "SpanID": "SPAN_ID_ROOT", ...}, ...
}
{
"Name": "ShortenerService.CreateShortLink.Attempt1", // 更深层子Span
"SpanContext": { ... "TraceID": "TRACE_ID_A", "SpanID": "SPAN_ID_ATTEMPT_1", ... },
"Parent": {"TraceID": "TRACE_ID_A", "SpanID": "SPAN_ID_SERVICE_CREATE", ...}, ...
}
{
"Name": "Store.FindByShortCode", // 模拟的store层span
"SpanContext": { ... "TraceID": "TRACE_ID_A", "SpanID": "SPAN_ID_STORE_FIND", ... },
"Parent": {"TraceID": "TRACE_ID_A", "SpanID": "SPAN_ID_ATTEMPT_1", ...}, ...
}
// ...
你会看到一个Trace(由相同的 TraceID 标识)包含了多个Span,它们之间通过 Parent.SpanID 形成了调用树,清晰地展示了请求从HTTP入口到Service层(包括内部重试和模拟的Store调用)的执行路径和耗时。
通过集成OpenTelemetry并进行适当的手动/自动插桩,我们就为“短链接服务”(即使是单体)赋予了基础的链路追踪能力。虽然本节课我们只用了 stdout 输出,但在生产环境中,只需将 InitTracerProvider 中的Exporter替换为指向Jaeger、Tempo或OTel Collector的Exporter,就能将这些宝贵的追踪数据发送到专业的后端系统进行分析和可视化了。
小结
这一节课,我们在上节课的基础上,继续完善“短链接服务”从设计图纸到实际工程的过程,引入了基础可观测性—— Metrics和Tracing。
-
使用
prometheus/client_golang为应用暴露了基础的Metrics指标(HTTP请求计数和延迟),并通过HTTP中间件进行了自动收集。 -
利用
OpenTelemetry Go SDK实现了基础的链路追踪,通过otelhttp中间件自动追踪了HTTP服务器请求,并在Service层演示了如何手动创建和管理子Span,最后通过标准输出Exporter直观地展示了追踪数据。
通过这两节课讲的所有步骤,我们的服务已经不再是简单的业务逻辑堆砌,而是开始具备了现代Go应用应有的工程化基础和初步但关键的可观测性能力。它能够灵活配置,输出结构化、带上下文的日志,暴露核心性能指标,并能追踪请求在其内部主要组件间的流转路径和耗时。
下一节课,我们将聚焦于进一步提升这个“短链接服务”的质量和可维护性,将它打磨得更加完善和专业,并为最终的部署做好准备。
思考题
在我们的“短链接服务”实战中,我们努力地通过依赖注入,将一个统一配置的、带有服务上下文的 slog.Logger 实例传递给了各个核心组件(如Store、Service、Handler)。
然而,请你仔细观察一下 internal/tracing/tracer.go 中的 InitTracerProvider 函数。你会发现,它在初始化过程中打印日志时,并没有使用我们精心构建的应用级 logger 实例,而是可能直接使用了 log/slog 的默认行为(或者一个在函数内部创建的临时logger)。这会导致这部分的启动日志可能缺少我们期望的全局上下文(如 service_name),或者其格式/级别与应用其他部分的日志不完全一致。
请你动手实践一下,如何重构代码,使得 InitTracerProvider 函数也能够使用我们从 main 函数开始创建和传递的应用级日志记录器?
完成这个小重构,你将更深刻地体会到在整个应用中保持依赖注入和日志记录一致性的重要性,这是构建清晰、可维护的Go应用的一个关键细节。欢迎在留言区分享你的修改思路或代码片段!
欢迎在留言区分享你的思考和见解!我是Tony Bai,我们下节课再见。