实战串讲(工程篇):“短链接服务”的工程化实践(上)
你好!我是Tony Bai。欢迎来到我们Go语言进阶课中“工程实践”模块的实战串讲。
在模块二“设计先行,奠定高质量代码基础”的实战部分,我们已经为“短链接服务”这个项目一同绘制了清晰的设计蓝图。我们确定了其分层架构,规划了项目应有的目录结构,定义了核心的业务接口(如 Store 接口、 ShortenerService 接口),设计了对外暴露的API端点,并为错误处理和API响应制定了初步的策略。可以说,我们已经有了“建筑图纸”。
现在,是时候拿起“工具”,将这些精心的设计思想转化为实实在在的、能够运行的Go代码了。更重要的是,我们不仅仅要让它“能跑起来”,更要为其注入生产环境所必需的“工程灵魂”。一个能在生产环境稳定运行、易于维护、问题可追溯的应用,远不止实现核心业务逻辑那么简单。它需要一个健壮的应用骨架来承载,需要灵活的配置管理来适应不同环境,需要清晰的日志记录来洞察运行状态,还需要完善的可观测性体系来保障其健康。这些,正是我们在 模块三“工程实践,锻造生产级Go服务” 中一直学习和强调的重点。
接下来的两节课,我们将亲自动手为“短链接服务”搭建起基础的工程框架,并集成一系列关键的工程化组件。这节课,我们先一起完成以下几个核心步骤:
-
搭建应用骨架:这包括创建程序的入口(
main.go),规划清晰的初始化流程,实现依赖的手动注入,并集成优雅的服务器退出机制。这部分内容将直接应用我们应用骨架这节课中学到的知识。 -
集成配置管理:我们将引入
spf13/viper库,让我们的服务能够从配置文件和环境变量中灵活加载所需的配置项,例如服务器端口、日志级别等。这对应了核心组件这一节课中关于配置管理的实践。 -
集成结构化日志:我们将使用Go 1.21+ 内置的
log/slog包,为服务实现结构化日志记录,确保日志信息既易于阅读,也便于机器解析和后续的集中式分析。这同样呼应了核心组件这一节课中关于日志最佳实践的内容。
注:这两节课的代码示例将侧重于这些工程实践的集成过程,核心的短链接业务逻辑(如短码生成算法、存储的具体实现等)会做相应的简化处理,以便我们能更聚焦于工程化本身。我们假设你已经对Go的基础语法和之前课程中涉及的包(如
net/http、context)有基本了解。
让我们开始动手,一步步地将“短链接服务”从设计图纸变为一个初具生产雏形的Go应用吧!
项目初始化与应用骨架搭建
任何一个Go项目的开始,都离不开合理的项目结构和清晰的应用入口。这将直接影响后续代码的组织、可维护性以及团队协作的效率。我们将首先为“短链接服务”奠定这个基础。
项目结构回顾与创建
在设计篇实战中,我们已经初步规划了项目的目录结构。现在,为了更好地配合后续的工程化实践,我们可以对其进行微调和确认。一个推荐的、符合社区习惯并适合我们实战的结构如下:
shortlink-service/
├── cmd/
│ └── server/ # HTTP服务的主程序入口
│ └── main.go
├── configs/ # 存放配置文件
│ └── config.yaml # 示例配置文件 (后续添加)
├── internal/ # 项目内部代码,不希望被外部项目直接导入
│ ├── app/ # (新增) 应用核心,封装启动、依赖注入、关闭等逻辑
│ │ └── app.go
│ ├── config/ # 配置加载与管理模块
│ │ └── config.go
│ ├── handler/ # HTTP请求处理器 (API层)
│ │ └── link_handler.go # (业务逻辑相关,本讲简化)
│ ├── service/ # 业务逻辑层
│ │ └── shortener_service.go # (业务逻辑相关,本讲简化)
│ ├── store/ # 数据存储层接口与实现
│ │ ├── store.go // Store接口定义 (业务逻辑相关,本讲简化)
│ │ └── memory/ // 内存存储实现 (用于本讲示例)
│ │ └── memory.go // (业务逻辑相关,本讲简化)
│ ├── middleware/ // HTTP中间件
│ │ ├── metrics.go // (后续添加)
│ │ └── logger.go // (可选,或直接在router/handler中使用slog)
│ ├── metrics/ // Prometheus Metrics定义
│ │ └── metrics.go // (后续添加)
│ └── tracing/ // OpenTelemetry Tracing 初始化
│ └── tracer.go // (后续添加)
├── pkg/ // (可选) 如果有可以被外部项目复用的库代码
│ └── lifecycle/ // 例如,通用的组件生命周期管理接口
│ └── lifecycle.go // (本讲将不直接使用,但为体现完整性保留)
├── go.mod
└── go.sum
这次的结构与设计篇相比,主要变化是新增 internal/app/app.go。我们将遵循应用骨架的建议,将核心的初始化、组件编排和生命周期管理逻辑从 main.go 中抽离出来,放到一个专门的 App 结构体及其方法中。这使得 main.go 变得非常“薄”,主要负责创建和运行这个 App 实例,也为后续的单元测试(如果需要测试启动和关闭逻辑)提供了便利。此外先前的idgen和shorten包都合并简化到了service包中。
你可以使用 mkdir -p 命令快速创建这些目录(除了 go.mod 和 go.sum,它们由Go模块命令生成)。然后在项目根目录( shortlink-service/)下运行 go mod init github.com/your_org/shortlink(请替换为你的实际模块路径)来初始化Go模块。
项目结构就绪后,我们来构建应用的入口和核心初始化流程。
构建应用入口与核心初始化流程
正如我们在应用骨架这节课中所强调的,一个良好的应用应该有一个清晰的启动流程。我们将 cmd/server/main.go 设计得非常简洁,它只负责引导和运行应用的核心逻辑,而将具体的初始化、组件编排和生命周期管理委托给 internal/app/app.go 中定义的 App 结构体。
应用核心 internal/app/app.go
我们先定义 App 结构体和其 Run 方法,其中 Run 方法内部会以注释的形式规划出后续要逐步填充的初始化和运行步骤。
// internal/app/app.go
package app
import (
"context"
"errors"
"fmt"
"io" // For store.Close
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
// 应用内部包
"github.com/your_org/shortlink/internal/config"
"github.com/your_org/shortlink/internal/handler"
appMetrics "github.com/your_org/shortlink/internal/metrics"
"github.com/your_org/shortlink/internal/middleware"
"github.com/your_org/shortlink/internal/service"
"github.com/your_org/shortlink/internal/store" // Store接口
"github.com/your_org/shortlink/internal/store/memory" // 内存存储实现
appTracing "github.com/your_org/shortlink/internal/tracing"
// 第三方库
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
// App 封装了应用的所有依赖和启动关闭逻辑
type App struct {
appName string
serviceVersion string
logger *slog.Logger
cfg *config.Config
store store.Store
shortenerSvc service.ShortenerService
tracerProvider *sdktrace.TracerProvider
httpServer *http.Server
}
// 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,
}
app.logger.Info("Creating new application instance...",
slog.String("appName", app.appName),
slog.String("version", app.serviceVersion),
)
// --- [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)
}
}
// 1b. 初始化 Metrics (Prometheus Go运行时等)
appMetrics.Init()
app.logger.Info("Prometheus Go runtime metrics collectors registered.")
// --- [App.New - 初始化阶段 2] ---
// 初始化核心业务依赖 (层级:Store -> Service -> Handler)
app.logger.Debug("Initializing core dependencies...")
// 2a. 初始化 Store 层
switch strings.ToLower(app.cfg.Store.Type) {
case "memory":
app.store = memory.NewStore(app.logger.With("datastore", "memory"))
app.logger.Info("Initialized in-memory store.")
default:
err := fmt.Errorf("unsupported store type from config: %s", app.cfg.Store.Type)
app.logger.Error("Failed to initialize store", slog.Any("error", err))
return nil, err
}
// 2b. 初始化 Service 层 (注入Store)
app.shortenerSvc = service.NewShortenerService(app.store, app.logger.With("layer", "service"))
app.logger.Info("Shortener service initialized.")
// 2c. 初始化 Handler 层 (注入Service)
linkHdlr := handler.NewLinkHandler(app.shortenerSvc, app.logger.With("layer", "handler"))
app.logger.Info("Link handler initialized.")
// --- [App.New - 初始化阶段 3] ---
// 创建HTTP Router并注册所有路由
mux := http.NewServeMux()
// 3a. 注册业务路由
mux.HandleFunc("POST /api/links", linkHdlr.CreateShortLink)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/metrics" || strings.HasPrefix(path, "/debug/pprof") {
return // 这些由诊断路由处理
}
if r.Method == http.MethodGet && len(path) > 1 && path[0] == '/' && !strings.Contains(path[1:], "/") {
shortCode := path[1:]
linkHdlr.RedirectShortLink(w, r, shortCode)
return
}
http.NotFound(w, r)
})
// 3b. 注册诊断路由 (Metrics, pprof)
mux.Handle("/metrics", promhttp.Handler())
mux.HandleFunc("/debug/pprof/", http.DefaultServeMux.ServeHTTP) // 假设pprof已注册到DefaultServeMux
app.logger.Info("HTTP routes registered.")
// --- [App.New - 初始化阶段 4] ---
// 应用HTTP中间件 (顺序很重要)
var finalHandler http.Handler = mux
// 4a. 应用 Metrics 中间件
finalHandler = middleware.Metrics(finalHandler)
app.logger.Info("Applied HTTP Metrics middleware.")
// 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.")
}
// 4c. 应用 Logging 中间件
finalHandler = middleware.RequestLogger(app.logger)(finalHandler)
app.logger.Info("Applied HTTP Request Logging middleware.")
// --- [App.New - 初始化阶段 5] ---
// 创建并配置最终的HTTP服务器
app.httpServer = &http.Server{
Addr: ":" + app.cfg.Server.Port,
Handler: finalHandler,
ReadTimeout: app.cfg.Server.ReadTimeout,
WriteTimeout: app.cfg.Server.WriteTimeout,
IdleTimeout: app.cfg.Server.IdleTimeout,
}
app.logger.Info("HTTP server and dependencies initialized successfully.", slog.String("listen_addr", app.httpServer.Addr))
return app, nil
}
// Run 启动应用并阻塞,直到接收到退出信号并完成优雅关闭
func (a *App) Run() error {
a.logger.Info("Starting application run cycle...",
slog.String("appName", a.appName),
slog.String("version", a.serviceVersion),
)
// --- [App.Run - 运行阶段 1] ---
// 异步启动HTTP服务器
errChan := make(chan error, 1)
go func() {
a.logger.Info("HTTP server starting to listen and serve...")
if err := a.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
a.logger.Error("HTTP server ListenAndServe failed", slog.Any("error", err))
errChan <- err
}
close(errChan)
}()
a.logger.Info("HTTP server startup process initiated.")
// --- [App.Run - 运行阶段 2] ---
// 实现优雅退出:阻塞等待OS信号或服务器启动错误
quitChannel := make(chan os.Signal, 1)
signal.Notify(quitChannel, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-quitChannel:
a.logger.Warn("Received shutdown signal, initiating graceful shutdown...",
slog.String("signal", sig.String()),
)
case err := <-errChan:
if err != nil {
a.logger.Error("HTTP server failed to start, initiating shutdown...", slog.Any("error", err))
}
}
// --- [App.Run - 运行阶段 3] ---
// 执行优雅关闭流程
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), a.cfg.Server.ShutdownTimeout)
defer cancelShutdown()
a.logger.Info("Attempting to gracefully shut down the HTTP server...",
slog.Duration("shutdown_timeout", a.cfg.Server.ShutdownTimeout),
)
if err := a.httpServer.Shutdown(shutdownCtx); err != nil {
a.logger.Error("HTTP server graceful shutdown failed or timed out", slog.Any("error", err))
} else {
a.logger.Info("HTTP server stopped gracefully.")
}
// --- [App.Run - 运行阶段 4] ---
// 清理其他应用资源
a.logger.Info("Cleaning up other application resources...")
if a.store != nil {
if closer, ok := a.store.(io.Closer); ok {
if err := closer.Close(); err != nil {
a.logger.Error("Error closing store", slog.Any("error", err))
} else {
a.logger.Info("Store closed successfully.")
}
}
}
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结构体:-
这是一个核心的容器(Container)或编排者(Orchestrator)结构体。
-
它封装了应用运行所需的所有关键依赖,如配置(
cfg)、日志记录器(logger)、数据存储实例(store)、业务服务实例(shortenerSvc)、HTTP服务器(httpServer)以及其他需要生命周期管理的组件(如tracerProvider)。 -
将所有依赖集中在一个结构体中,使得应用的状态和组件关系非常清晰,也便于在不同部分(如
New和Run方法)之间共享这些依赖。
-
New()函数:-
这是应用的构造器(Constructor),负责静态的、一次性的初始化工作。
-
它的核心职责是依赖注入(Dependency Injection)。它接收最基础的依赖(如配置和引导日志器),然后按照“被依赖者先于依赖者”的顺序,一步步创建和组装应用的所有组件。例如,它先创建
Store,然后将Store注入到Service的构造函数中,再将Service注入到Handler的构造函数中。 -
New()函数完成了所有组件的“准备工作”,包括创建HTTP Router、应用中间件,以及配置最终的http.Server实例。 -
如果任何关键的初始化步骤失败,
New()会返回一个错误,阻止应用启动。
-
Run()方法:-
这是应用的核心执行体(Executor),负责管理应用的运行时生命周期。
-
它的第一步是启动应用的主要服务(在我们的例子中,是异步启动HTTP服务器)。
-
然后,它会阻塞主goroutine,通过监听操作系统的
SIGINT和SIGTERM信号来等待退出指令。 -
一旦收到退出信号,它会启动一个优雅的关闭(Graceful Shutdown)流程。这包括:
-
调用
httpServer.Shutdown(),给正在处理的HTTP请求一个完成的时间窗口。 -
在服务器关闭后,清理其他需要释放的资源,如关闭数据库连接池、确保追踪数据被完全导出等。
-
-
Run()方法在整个优雅关闭流程完成后才会返回,向调用者(main()函数)表明应用已成功终止。
-
通过 New() 负责“组装”和 Run() 负责“运行与关闭”的明确职责划分,我们的 app.go 实现了一个清晰、健壮且符合Go应用骨架最佳实践的核心。
“薄” main 函数( cmd/server/main.go)
main.go 的职责变得非常简单:创建 App 实例并调用其 Run 方法。我们来看一下 main.go 的主要源码:
// cmd/server/main.go
package main
import (
"fmt"
"log/slog" // 用于在app.New失败时记录日志
"os"
"strings" // 用于initSlogLogger
"time" // 用于initSlogLogger
"github.com/your_org/shortlink/internal/app" // 导入app核心包
"github.com/your_org/shortlink/internal/config" // 导入配置包
)
const (
// defaultAppName 和 defaultAppVersion 作为硬编码的默认值,
// 它们可以在没有配置文件或配置项时的最终兜底。
// 在实际项目中,版本号更推荐通过编译时注入(ldflags)来设置。
defaultAppName = "ShortLinkService"
defaultAppVersion = "0.1.0"
)
// bootstrapLogger 是一个引导日志器,专门用于应用启动的极早期阶段。
// 它的主要目的是在`app.New()`函数执行过程中或失败时,能够以结构化的方式记录日志。
// 它直接从传入的(可能已加载的)配置中获取日志级别和格式,
// 如果配置未加载,则使用安全的默认值。
// 注意:这个logger是临时的,App实例创建成功后,会使用其内部更完善的、基于完整配置的logger。
func bootstrapLogger(cfg *config.Config) *slog.Logger {
var level slog.Level
logLevelStr := "info" // 安全的默认日志级别
logFormatStr := "text" // 默认使用text格式,便于在启动时直接在控制台阅读
// 如果配置文件已成功加载,则使用其中的设置
if cfg != nil {
logLevelStr = cfg.Server.LogLevel
logFormatStr = cfg.Server.LogFormat
}
switch strings.ToLower(logLevelStr) {
case "debug":
level = slog.LevelDebug
case "info":
level = slog.LevelInfo
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
// 如果配置中的日志级别无效,也使用安全的默认值
level = slog.LevelInfo
}
var handler slog.Handler
// 创建一个Handler,配置其日志级别和时间戳格式。
// 注意:AddSource设为false,因为引导日志通常不需要源码位置,且可以提升一点点性能。
// 日志输出到标准错误流(os.Stderr),这是记录启动过程错误的常见做法。
handlerOpts := &slog.HandlerOptions{AddSource: false, Level: level, ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
a.Value = slog.StringValue(a.Value.Time().Format(time.RFC3339))
}
return a
}}
if strings.ToLower(logFormatStr) == "json" {
handler = slog.NewJSONHandler(os.Stderr, handlerOpts)
} else {
handler = slog.NewTextHandler(os.Stderr, handlerOpts)
}
// 返回一个带有"bootstrap_phase"标识的logger,便于区分这是启动阶段的日志。
return slog.New(handler).With(slog.String("bootstrap_phase", "main"))
}
// main 函数是整个应用程序的入口点。
// 它的职责被设计得非常“薄”,主要负责引导和协调应用的创建与运行。
func main() {
// --- 步骤 0: (可选) 解析最顶层的命令行参数 ---
// 例如,通过`flag`包解析`-config`参数来指定配置文件的路径。
// configFile := flag.String("config", "./configs/config.yaml", "Path to config file")
// flag.Parse()
// 为了保持本示例的简洁性,我们暂时直接使用固定的路径和名称。
// --- 步骤 1: 加载配置 ---
// 这是应用启动的第一步关键操作。后续所有组件的初始化都依赖于这份配置。
// LoadConfig内部会处理文件查找、读取、解析以及环境变量的覆盖。
cfg, err := config.LoadConfig("./configs", "config", "yaml")
if err != nil {
// 如果连配置都加载失败,这通常是一个致命错误,无法继续。
// 此时日志系统可能还未初始化,所以使用`fmt.Fprintf`直接向标准错误输出。
fmt.Fprintf(os.Stderr, "FATAL: Error loading initial application configuration: %v\n", err)
os.Exit(1) // 以非零状态码退出,表示启动失败。
}
// --- 步骤 2: 初始化引导日志器 ---
// 基于刚刚加载的配置(或其默认值),创建一个临时的引导日志器。
// 这个logger主要用于记录`app.New()`的执行过程,以及在`app.New()`失败时能输出结构化的错误信息。
mainLogger := bootstrapLogger(&cfg)
// 我们可以在这里选择是否立即将此logger设置为全局默认实例(`slog.SetDefault`)。
// 通常,更推荐的做法是在app.New()内部,当所有配置都最终确定后,
// 创建并设置最终的、功能完备的全局默认logger。
mainLogger.Info("Configuration loaded, attempting to create application instance...")
// --- 步骤 3: 创建App实例 ---
// 这是整个初始化过程的核心。我们将配置和引导日志器注入到`app.New()`函数中。
// `app.New()`内部会完成所有组件(如Store, Service, Handler, HTTP Server等)的实例化和依赖注入。
appNameFromConfig := cfg.AppName
if appNameFromConfig == "" { // 如果配置中未指定appName,则使用硬编码的默认值
appNameFromConfig = defaultAppName
}
application, err := app.New(&cfg, mainLogger, appNameFromConfig, defaultAppVersion)
if err != nil {
// 如果`app.New()`返回错误,说明应用的核心组件未能成功创建。
// 我们使用刚刚创建的引导日志器来记录这个致命错误,然后退出。
mainLogger.Error("FATAL: Failed to create application instance", slog.Any("error", err))
os.Exit(1)
}
mainLogger.Info("Application instance created successfully.")
// --- 步骤 4: 运行App ---
// `application.Run()`是一个阻塞调用,它封装了应用的整个运行时生命周期,
// 包括启动所有服务(如HTTP服务器)、监听操作系统信号及执行优雅关闭。
if err := application.Run(); err != nil {
// 如果`Run()`方法返回错误,表明应用在运行或关闭过程中遇到了未能处理的严重问题。
// 此时,应用内部的、功能完备的日志系统应该已经可用,我们可以通过它来记录最终的致命错误。
// (假设App实例有一个Logger()方法可以暴露其内部的logger)
application.Logger().Error("FATAL: Application run failed and terminated", slog.Any("error", err))
os.Exit(1)
}
// 如果`Run()`正常返回nil,说明应用已成功完成优雅关闭流程。
// 相关的成功日志应在app.Run()内部或组件的Stop方法中打印。
// main函数在此处正常结束,隐式返回os.Exit(0)。
mainLogger.Info("Application main function exiting cleanly.")
}
其中, cmd/server/main.go 文件是我们“短链接服务”的唯一入口点。遵循我们在应用骨架这节课中探讨的“薄 main ,厚 App ”的设计原则,这个 main 函数被设计得非常简洁,其核心职责可以概括为以下几个清晰的步骤:
-
引导与协调:
main函数的首要任务是作为应用的引导程序。它不包含任何具体的业务逻辑或复杂的组件初始化代码,而是负责协调整个应用的启动流程。 -
早期配置加载:它是应用中第一个执行实际操作的部分,即调用
config.LoadConfig()来加载应用的配置。这是后续所有初始化工作的基础。如果配置加载失败,main函数会立即以失败状态退出,因为没有配置,应用无法继续。 -
创建引导日志器:在加载完配置后,
main函数会立即利用这些配置(或其默认值)创建一个临时的“引导日志器”。这个日志器的主要作用是记录应用核心实例app.App的创建过程,特别是在app.New()函数执行失败时,能够以结构化的方式输出错误信息,这比简单的fmt.Fprintf更利于诊断。 -
实例化应用核心(
app.New):main函数将加载到的配置和创建的引导日志器,作为参数传递给app.New()函数,请求创建一个完整的应用实例。main函数将应用的创建和所有复杂依赖注入的职责完全委托给了app.App的构造器。 -
运行应用(
application.Run):一旦app.New()成功返回应用实例,main函数就调用其Run()方法。这是一个阻塞调用,它将程序的控制权完全交给了App实例,由App实例负责管理应用的整个运行时生命周期,包括启动服务、监听信号和执行优雅关闭。 -
处理致命错误与退出:
main函数是应用生命周期的最终守护者。它会检查app.New()和application.Run()的返回值。如果任何一个步骤返回了错误,main函数会记录一条致命错误日志,并调用os.Exit(1)以非零状态码退出,向操作系统或外部监控系统明确表示应用启动或运行失败。如果Run()正常返回(err == nil),则意味着应用已成功完成优雅关闭,main函数随之正常结束。
通过这种职责划分,我们的 main.go 保持了极高的可读性和单一职责,它只关心“如何正确地启动和结束应用”,而将“应用由什么组成以及如何运作”的复杂性完全封装在了 internal/app 包中。这不仅使得代码结构更清晰,也为后续对应用核心逻辑进行单元测试提供了可能。
同时,这种“薄 main,厚 App”的结构,使得核心的应用逻辑(在 internal/app/app.go 中)更容易进行单元测试(例如,我们可以测试 App.Run() 在不同配置或模拟依赖下的行为,而无需实际启动HTTP服务器或监听信号)。
为了更直观地理解我们规划的这个核心流程,可以用下图来表示上面 main函数 和 app 创建和运行的核心流程:

这个清晰的骨架和流程规划,为我们后续逐步集成各个工程组件提供了坚实的基础。在所有组件被启动之前,我们首先要确保应用具备一个核心的健壮性特征——优雅退出。
实现优雅退出机制
一个生产级的HTTP服务必须能够响应操作系统发送的终止信号(如用户在终端按下 Ctrl+C 时产生的 SIGINT,或者部署系统如Kubernetes停止Pod时发送的 SIGTERM),并执行一个“优雅”的关闭流程,而不是被粗暴地杀死。
优雅退出意味着:
-
服务器立即停止接受任何新的入站连接。
-
给当前正在处理的请求一个合理的时间窗口来完成它们的处理。
-
在程序最终退出前,有序地释放所有占用的关键资源(如数据库连接、文件句柄、追踪数据缓冲区等)。
Go标准库的 net/http.Server 类型提供了 Shutdown(ctx context.Context) 方法,它与Go的信号处理和 context 机制结合,可以完美地支持这一需求。我们已经在 internal/app/app.go 的 Run() 方法末尾规划并实现了优雅退出的逻辑,现在我们来详细解读这部分核心代码。
我们来解析下 internal/app/app.go 中优雅退出部分的实现,先来看 App.Run() 方法中是如何实现这个流程的。在异步启动HTTP服务器之后,主goroutine会执行以下逻辑:
// (在App.Run()方法内部,异步启动HTTP服务器之后)
// --- [App.Run - 运行阶段 2] ---
// 实现优雅退出:阻塞等待OS信号或服务器启动错误
quitChannel := make(chan os.Signal, 1)
// 通知channel监听指定的信号。缓冲为1确保即使信号连续快速发送,也不会丢失至少一个。
signal.Notify(quitChannel, syscall.SIGINT, syscall.SIGTERM)
// 阻塞等待以下两个事件之一发生:
// 1. 从quitChannel中接收到一个操作系统退出信号。
// 2. 从errChan中接收到一个错误,表明HTTP服务器在启动时就失败了。
select {
case sig := <-quitChannel:
a.logger.Warn("Received shutdown signal, initiating graceful shutdown...",
slog.String("signal", sig.String()), // 记录收到的具体信号
)
case err := <-errChan:
// 如果ListenAndServe在启动时就失败了(例如,端口被占用),
// 我们也需要触发后续的关闭和清理流程。
if err != nil {
a.logger.Error("HTTP server failed to start, initiating shutdown...", slog.Any("error", err))
}
}
// --- [App.Run - 运行阶段 3] ---
// 执行优雅关闭流程
// 从配置中获取优雅关闭的超时时间
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), a.cfg.Server.ShutdownTimeout)
defer cancelShutdown() // 确保在函数退出时,即使是正常退出,也调用cancel释放context相关资源
a.logger.Info("Attempting to gracefully shut down the HTTP server...",
slog.Duration("shutdown_timeout", a.cfg.Server.ShutdownTimeout),
)
// 调用http.Server的Shutdown方法。
if err := a.httpServer.Shutdown(shutdownCtx); err != nil {
// 如果Shutdown方法返回错误(通常是因为提供的context超时了,但仍有连接未关闭),
// 则记录这个错误。此时,服务器可能没有完全优雅地关闭所有连接。
a.logger.Error("HTTP server graceful shutdown failed or timed out", slog.Any("error", err))
} else {
a.logger.Info("HTTP server stopped gracefully.")
}
// --- [App.Run - 运行阶段 4] ---
// 清理其他应用资源
a.logger.Info("Cleaning up other application resources...")
if a.store != nil { // 关闭Store (如果它实现了io.Closer)
if closer, ok := a.store.(io.Closer); ok {
if err := closer.Close(); err != nil {
a.logger.Error("Error closing store", slog.Any("error", err))
} else {
a.logger.Info("Store closed successfully.")
}
}
}
if a.tracerProvider != nil { // 关闭TracerProvider
// 为TracerProvider的Shutdown也设置一个独立的、较短的超时
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.Run() 方法结束
代码说明:
-
监听信号:我们创建了一个缓冲大小为1的channel
quitChannel,并通过signal.Notify让它监听syscall.SIGINT(通常由Ctrl+C触发)和syscall.SIGTERM(标准的终止信号)这两个最常见的退出信号。 -
阻塞等待:主goroutine通过一个
select语句阻塞,它会等待两个事件中的任何一个先发生:-
从
quitChannel接收到一个OS信号。 -
从
errChan接收到一个错误。errChan是我们用来从异步启动的HTTP服务器goroutine中接收启动错误的通道。如果服务器启动失败(例如,端口被占用),ListenAndServe会立即返回错误,这个错误会被发送到errChan,从而也能触发关闭流程。
-
-
超时控制的关闭:
-
一旦收到退出信号或启动错误,程序会继续执行。我们从配置中获取
shutdownTimeout,并使用context.WithTimeout创建一个带有此超时时间的shutdownCtx。这个context的作用是为整个优雅关闭过程设定一个总的最后期限。 -
调用
a.httpServer.Shutdown(shutdownCtx)。这个方法非常关键,它会平滑地关闭服务器:首先停止接受新连接,然后等待所有已建立的连接上的活动请求处理完毕。如果所有请求在shutdownCtx超时之前都处理完了,Shutdown会返回nil。如果超时了但仍有请求在处理,它会返回context.DeadlineExceeded错误,并强制关闭剩余连接。
-
-
清理其他资源:在HTTP服务器关闭之后,我们还预留了位置用于清理应用中的其他关键资源。这是一个非常重要的步骤,确保没有资源泄漏。
-
关闭Store:如果我们的
store实例(例如,一个数据库连接池)实现了io.Closer接口,我们应该在这里调用其Close()方法。 -
关闭TracerProvider:如果我们初始化了OpenTelemetry的
TracerProvider,必须在程序退出前调用其Shutdown()方法,以确保所有缓冲在内存中的追踪数据(spans)都被成功导出到后端。否则,可能会丢失最后的追踪信息。
-
要验证这个机制是否工作,你可以在本地运行你的服务:
-
在
shortlink-service项目根目录下,运行go run ./cmd/server/main.go。 -
服务启动后,在终端按下
Ctrl+C(这会发送SIGINT信号)。 -
观察控制台的日志输出,你应该能清晰地看到整个优雅关闭的流程日志:“Received shutdown signal…”, “Attempting to gracefully shut down…”, “HTTP server stopped gracefully.”, “Cleaning up other application resources…”, 直到最后的“Application has shut down completely.”。如果在关闭超时(例如,我们配置的20秒)内有正在处理的HTTP请求,服务器会尝试等待它们完成。
至此,我们的应用骨架不仅具备了清晰的初始化流程和依赖注入结构,更拥有了生产级服务所必需的、非常重要的优雅退出机制。这是构建一个健壮、可靠的Go服务的基础。
接下来,我们将为这个已经搭建好的骨架,逐步填充上同样重要的“神经系统”——配置管理和结构化日志。
配置管理与结构化日志构件集成
一个生产级的应用,其行为不能硬编码在代码中,而是需要通过外部配置来驱动。同时,它也需要以结构化的、易于分析的方式记录其运行时的关键信息。这两者是应用可运维性的核心,我们将逐步把这两大核心组件集成到我们 App.Run() 方法的初始化阶段。
集成Viper进行配置管理
我们选择 spf13/viper 作为配置管理库,它功能强大,支持多种配置源(文件、环境变量、远程等)和格式(YAML、JSON、TOML等)。在本例中,我们将主要使用YAML文件,并允许环境变量覆盖。
添加Viper依赖
在你的项目根目录下运行:
go get github.com/spf13/viper
# Viper 可能间接依赖其他库,go get 会处理
# 如果你需要支持特定格式如TOML,可能需要额外get对应的解析库的Viper驱动
# go get github.com/spf13/pflag # 通常与Viper一起使用处理命令行标志,但本例暂不直接用命令行覆盖Viper
go get gopkg.in/yaml.v3 # Viper 使用它来解析YAML
定义配置结构体( internal/config/config.go)
这个文件我们在之前的 App.Run() 的注释中已经规划了,现在我们来创建并完善它。
// internal/config/config.go
package config
import (
"fmt"
"os"
"strings"
"time"
"github.com/spf13/viper"
)
// Config 是应用的总配置结构体
type Config struct {
AppName string `mapstructure:"appName"`
Server ServerConfig `mapstructure:"server"`
Store StoreConfig `mapstructure:"store"`
Tracing TracingConfig `mapstructure:"tracing"`
}
// ServerConfig 包含HTTP服务器相关的配置
type ServerConfig struct {
Port string `mapstructure:"port"`
LogLevel string `mapstructure:"logLevel"`
LogFormat string `mapstructure:"logFormat"` // "text" or "json"
ReadTimeout time.Duration `mapstructure:"readTimeout"`
WriteTimeout time.Duration `mapstructure:"writeTimeout"`
IdleTimeout time.Duration `mapstructure:"idleTimeout"`
ShutdownTimeout time.Duration `mapstructure:"shutdownTimeout"`
}
// StoreConfig 包含与存储相关的配置
type StoreConfig struct {
Type string `mapstructure:"type"`
// DSN string `mapstructure:"dsn"` // Example for Postgres
}
// TracingConfig 包含与分布式追踪相关的配置
type TracingConfig struct {
Enabled bool `mapstructure:"enabled"`
OTELEndpoint string `mapstructure:"otelEndpoint"`
SampleRatio float64 `mapstructure:"sampleRatio"`
}
// LoadConfig 从指定路径加载配置文件并允许环境变量覆盖。
func LoadConfig(configSearchPath string, configName string, configType string) (cfg Config, err error) {
v := viper.New()
// 1. 设置默认值
v.SetDefault("appName", "ShortLinkServiceAppDefault")
v.SetDefault("server.port", "8080")
v.SetDefault("server.logLevel", "info")
v.SetDefault("server.logFormat", "json")
v.SetDefault("server.readTimeout", "5s")
v.SetDefault("server.writeTimeout", "10s")
v.SetDefault("server.idleTimeout", "120s")
v.SetDefault("server.shutdownTimeout", "15s")
v.SetDefault("store.type", "memory")
v.SetDefault("tracing.enabled", true)
v.SetDefault("tracing.otelEndpoint", "localhost:4317") // OTel Collector gRPC
v.SetDefault("tracing.sampleRatio", 1.0)
// 2. 设置配置文件查找路径、名称和类型
if configSearchPath != "" {
v.AddConfigPath(configSearchPath)
}
v.AddConfigPath(".")
v.SetConfigName(configName)
v.SetConfigType(configType)
// 3. 尝试读取配置文件
if errRead := v.ReadInConfig(); errRead != nil {
if _, ok := errRead.(viper.ConfigFileNotFoundError); !ok {
return cfg, fmt.Errorf("config: failed to read config file: %w", errRead)
}
fmt.Fprintf(os.Stderr, "[ConfigLoader] Warning: Config file '%s.%s' not found. Using defaults/env vars.\n", configName, configType)
} else {
fmt.Fprintf(os.Stdout, "[ConfigLoader] Using config file: %s\n", v.ConfigFileUsed())
}
// 4. 启用环境变量覆盖
v.SetEnvPrefix("SHORTLINK")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
// 5. Unmarshal到Config结构体
if errUnmarshal := v.Unmarshal(&cfg); errUnmarshal != nil {
return cfg, fmt.Errorf("config: unable to decode all configurations into struct: %w", errUnmarshal)
}
fmt.Fprintf(os.Stdout, "[ConfigLoader] Configuration loaded successfully. AppName: %s\n", cfg.AppName)
return cfg, nil
}
在代码中:
-
我们定义了
Config总结构体,以及嵌套的ServerConfig、StoreConfig、TracingConfig。注意字段上的mapstructure标签,Viper在Unmarshal时会使用它们。 -
LoadConfig函数:-
创建了一个新的
viper.Viper实例(推荐,而不是使用全局的viper.Get())。 -
设置了各种配置项的默认值(这是最低优先级的)。
-
配置了查找路径、文件名和类型。
-
尝试读取配置文件,如果只是文件未找到,则忽略错误,否则返回错误。
-
通过
v.SetEnvPrefix、v.AutomaticEnv、v.SetEnvKeyReplacer启用了环境变量覆盖。环境变量名如SHORTLINK_SERVER_PORT会自动映射到配置路径server.port。 -
最后,调用
v.Unmarshal(&cfg)将所有来源(默认值、文件、环境变量,按优先级合并后)的配置数据填充到Config结构体实例中。
-
创建示例配置文件( shortlink-service/configs/config.yaml)
# shortlink-service/configs/config.yaml
appName: "MyShortLinkServiceFromYAML"
server:
port: "8081" # 覆盖默认的8080
logLevel: "debug" # 覆盖默认的info
logFormat: "text" # 覆盖默认的json,便于本地开发时直接在控制台查看
readTimeout: "10s"
writeTimeout: "15s"
idleTimeout: "180s"
shutdownTimeout: "20s" # 优雅关闭的超时时间
store:
type: "memory" # 明确使用内存存储 (虽然这也是默认值)
# dsn: "postgres://user:pass@host:port/db?sslmode=disable" # 如果用postgres
tracing:
enabled: true # 明确启用追踪
otelEndpoint: "localhost:4317" # OTel Collector的gRPC地址
sampleRatio: 0.8 # 采样80%的追踪
我们在之前 main 函数中调用了LoadConfig函数对来自于 ./configs/config.yaml 配置文件中的核心参数(如应用名、服务器端口、日志级别和格式、各种超时时间、存储类型、追踪设置等)进行了加载,并且其中的任何项都可以通过设置对应的环境变量(例如 SHORTLINK_SERVER_PORT=9000, SHORTLINK_TRACING_OTELENDPOINT="jaeger-agent:4317")来进行覆盖。Viper会自动处理这些优先级和类型转换。
有了灵活的配置管理,下一步就是确保我们的应用能输出高质量的、结构化的日志,以便于监控和问题排查。
集成 log/slog 进行结构化日志
我们在核心组件这节课中详细学习了Go 1.21版本引入的官方结构化日志库 log/slog。它让我们能够方便地记录带有键值对属性的日志,非常适合在云原生环境中被日志收集和分析系统处理。现在,我们将其集成到我们的“短链接服务”中。
在 main 函数中创建引导日志器( cmd/server/main.go)
正如我们在 main.go 的设计中所见,它的职责之一是在应用核心( app.App)创建之前,就基于已加载的配置初始化一个引导日志器(Bootstrap Logger)。这个日志器的主要作用是记录应用启动的极早期阶段,特别是 app.New() 函数的执行过程,以及在 app.New() 失败时能够以结构化的方式输出错误信息。
我们来看一下 cmd/server/main.go 中的 bootstrapLogger 函数和它在 main 函数中的调用:
// cmd/server/main.go (相关部分)
// bootstrapLogger 是一个引导日志器,专门用于应用启动的极早期阶段。
// 它的主要目的是在 `app.New()` 函数执行过程中或失败时,能够以结构化的方式记录日志。
// 它直接从传入的(可能已加载的)配置中获取日志级别和格式,
// 如果配置未加载,则使用安全的默认值。
func bootstrapLogger(cfg *config.Config) *slog.Logger {
var level slog.Level
logLevelStr := "info" // 安全的默认日志级别
logFormatStr := "text" // 默认使用text格式,便于在启动时直接在控制台阅读
// 如果配置文件已成功加载,则使用其中的设置
if cfg != nil {
logLevelStr = cfg.Server.LogLevel
logFormatStr = cfg.Server.LogFormat
}
switch strings.ToLower(logLevelStr) {
case "debug":
level = slog.LevelDebug
case "info":
level = slog.LevelInfo
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
var handler slog.Handler
// 创建一个Handler,配置其日志级别和时间戳格式。
// 注意:AddSource设为false,因为引导日志通常不需要源码位置。
// 日志输出到标准错误流(os.Stderr),这是记录启动过程错误的常见做法。
handlerOpts := &slog.HandlerOptions{AddSource: false, Level: level, ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
a.Value = slog.StringValue(a.Value.Time().Format(time.RFC3339))
}
return a
}}
if strings.ToLower(logFormatStr) == "json" {
handler = slog.NewJSONHandler(os.Stderr, handlerOpts)
} else {
handler = slog.NewTextHandler(os.Stderr, handlerOpts)
}
// 返回一个带有"bootstrap_phase"标识的logger,便于区分这是启动阶段的日志。
return slog.New(handler).With(slog.String("bootstrap_phase", "main"))
}
func main() {
// ... (步骤 1: 加载配置 cfg) ...
// --- 步骤 2: 初始化引导日志器 ---
mainLogger := bootstrapLogger(&cfg)
mainLogger.Info("Configuration loaded, attempting to create application instance...")
// --- 步骤 3: 创建App实例,注入配置和引导日志器 ---
// ...
application, err := app.New(&cfg, mainLogger, appNameFromConfig, defaultAppVersion)
if err != nil {
mainLogger.Error("FATAL: Failed to create application instance", slog.Any("error", err))
os.Exit(1)
}
// ...
}
在代码中:
-
bootstrapLogger函数会根据从config.yaml(或环境变量)中加载的logLevel和logFormat来创建并返回一个*slog.Logger实例。 -
在
main函数中,我们在加载完配置后,立即调用bootstrapLogger创建mainLogger。 -
这个
mainLogger被用于记录后续app.New()的调用过程,并在app.New()失败时记录致命错误。 -
最重要的是,这个
mainLogger实例被作为参数注入到了app.New()函数中,App实例内部将基于这个引导日志器(或者重新创建一个更完善的)来构建其自身的日志能力。
在 App 核心中使用和传递Logger( internal/app/app.go)
app.New() 函数接收到引导日志器后,会将其保存为 App 实例的一个字段,并可能为其添加更多应用级别的上下文(如 component="appcore"),然后将其设置为全局默认logger。
// internal/app/app.go (New函数和App结构体相关部分)
// App 封装了应用的所有依赖...
type App struct {
// ...
logger *slog.Logger // 应用的主日志记录器
// ...
}
// New 创建一个新的App实例...
func New(cfg *config.Config, logger *slog.Logger, appName, version string) (*App, error) {
app := &App{
cfg: cfg,
// 使用传入的引导日志器,并为其添加一个固定的"component"属性
logger: logger.With("component", "appcore"),
appName: appName,
serviceVersion: version,
}
// (可选但推荐) 将App的最终logger设置为全局默认实例。
// 这样,在应用中任何未被显式注入logger的包中,都可以通过 slog.Info() 等函数进行日志记录。
slog.SetDefault(app.logger)
app.logger.Info("Creating new application instance...", /* ... */)
// --- 初始化核心依赖,并将logger注入 ---
// ...
// 2a. 初始化 Store 层
app.store = memory.NewStore(app.logger.With("datastore", "memory"))
app.logger.Info("Initialized in-memory store.")
// 2b. 初始化 Service 层 (注入Store和logger)
app.shortenerSvc = service.NewShortenerService(app.store, app.logger.With("layer", "service"))
app.logger.Info("Shortener service initialized.")
// 2c. 初始化 Handler 层 (注入Service和logger)
linkHdlr := handler.NewLinkHandler(app.shortenerSvc, app.logger.With("layer", "handler"))
app.logger.Info("Link handler initialized.")
// ... (后续的路由注册、中间件应用、服务器创建等)
return app, nil
}
代码说明:
-
app.New函数将传入的logger保存起来,并通过.With("component", "appcore")为其添加了新的上下文,表明后续由app实例直接打印的日志来自appcore组件。 -
依赖注入Logger:在初始化
store、service和handler等组件时,我们将app.logger(同样通过.With(...)为其添加了更具体的组件标识,如"datastore"、"layer")作为参数传递给了它们的构造函数。这确保了应用中的每个核心组件都拥有一个带有自身上下文的、配置统一的日志记录器。 -
设置全局默认Logger:
slog.SetDefault(app.logger)这一步非常关键。它使得在应用的任何地方,如果一个函数不方便(或没必要)通过参数接收logger实例,它依然可以通过调用包级别的slog.Info(...)、slog.Error(...)等函数来记录日志,这些日志会自动使用我们配置好的、带有全局属性的默认logger进行处理。
在业务逻辑中使用结构化日志(以Handler为例)
现在,我们的 LinkHandler 已经通过依赖注入获得了一个配置好的 slog.Logger 实例。它可以(也应该)在处理请求时,进一步为日志添加请求相关的上下文。
// internal/handler/link_handler.go (CreateShortLink方法示例)
func (h *LinkHandler) CreateShortLink(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 从handler持有的基础logger派生出一个与当前请求相关的logger
// 它会自动继承"component":"LinkHandler"等属性
requestLogger := h.logger.With(
slog.String("http_method", r.Method),
slog.String("http_path", r.URL.Path),
// 后续集成Tracing后,这里还可以从ctx中提取并添加TraceID
// slog.String("trace_id", trace.SpanFromContext(ctx).SpanContext().TraceID().String()),
)
// 使用这个requestLogger记录该请求生命周期内的所有日志
requestLogger.DebugContext(ctx, "Handler: Received request to create short link.")
var reqPayload CreateLinkRequest
if err := json.NewDecoder(r.Body).Decode(&reqPayload); err != nil {
requestLogger.ErrorContext(ctx, "Handler: Failed to decode request body.", slog.Any("error", err))
// ... 返回错误响应 ...
return
}
defer r.Body.Close()
requestLogger.InfoContext(ctx, "Handler: Processing create short link request.", slog.String("long_url", reqPayload.LongURL))
shortCode, err := h.svc.CreateShortLink(ctx, reqPayload.LongURL, reqPayload.UserID, reqPayload.OriginalURL, time.Time{})
if err != nil {
requestLogger.ErrorContext(ctx, "Handler: Service failed to create short link.",
slog.Any("error", err),
slog.String("long_url", reqPayload.LongURL),
)
// ... 返回错误响应 ...
return
}
// ... 返回成功响应 ...
requestLogger.InfoContext(ctx, "Handler: Successfully created short link.",
slog.String("short_code", shortCode),
slog.String("long_url", reqPayload.LongURL),
)
}
通过以上步骤,我们成功地将 slog 结构化日志系统深度集成到了我们的“短链接服务”中。现在,当服务运行时,它会根据我们的 config.yaml(或环境变量)的设置,输出格式统一、级别可控、并且富含上下文(全局、组件级、请求级)的日志。这些高质量的日志数据,为我们后续进行问题排查、行为审计和系统监控提供了坚实的基础。
小结
在这一节课中,我们迈出了将“短链接服务”从设计图纸变为实际工程的关键一步。我们一起:
-
搭建了应用的基本骨架(
main.go和internal/app/app.go),包含了清晰的初始化流程规划、手动依赖注入的结构,以及健壮的优雅退出机制。 -
集成了
spf13/viper进行灵活的配置管理,使得应用能够从YAML文件和环境变量中加载和合并配置。 -
引入了Go 1.21+的
log/slog进行结构化日志记录,并学习了如何在Handler和Service中添加丰富的上下文信息。
下一节课,我们将继续讲引入基础可观测性——Metrics和Tracing。
欢迎在留言区分享你的思考和见解!我是Tony Bai,我们下节课再见。