这是一份按步骤展开的说明文档。咱们从一个只能启动的空应用开始,每一步只增加一个能力: 先让 HTTP 路由工作起来,再把对象交给容器管理, 然后继续加入配置、依赖注入、外部客户端、条件注册、结构化日志,最后再到测试。 每一步都可以单独运行,对应的完整代码放在 examples 目录中。
第一步咱们先不写业务代码,只来确认 Go-Spring 应用怎么启动。
代码如下:
func main() {
gs.Run()
}完整代码在 examples/01-run-only/main.go。
上面这段代码虽然看起来很短,但是已经足够让程序进入 Go-Spring 的应用生命周期。
gs.Run() 会创建应用,加载配置,初始化日志,刷新 IoC 容器,启动内置 HTTP Server,
并监听 SIGINT / SIGTERM,最后在进程退出时还能执行优雅关闭。
使用下面的命令运行示例:
cd examples/01-run-only
go run .此时控制台会打印如下信息:
____ ___ ____ ____ ____ ___ _ _ ____
/ ___| / _ \ / ___| | _ \ | _ \ |_ _| | \ | | / ___|
| | _ | | | | _____ \___ \ | |_) | | |_) | | | | \| | | | _
| |_| | | |_| | |_____| ___) | | __/ | _ < | | | |\ | | |_| |
\____| \___/ |____/ |_| |_| \_\ |___| |_| \_| \____|
go-spring@v1.3.0 https://github.com/go-spring/
[INFO][2026-05-02T19:13:07.837][...ing/spring-core/gs/internal/gs_app/app.go:289] _app_def||msg=ready to serve requests
ready to serve requests 表示应用已经启动,并且成功监听 :9090。
使用下面的命令访问根路径:
curl http://127.0.0.1:9090/会得到:
404 page not found
这里 404 是预期结果。它说明 HTTP Server 已经启动了,只是还没有 handler 能处理这个路径。
按下 Ctrl+C 可以停止程序,接着 Go-Spring 进入关闭流程。
上一章的应用已经能够启动,但是还没有业务入口,所以任何请求都会返回 404。 现在咱们先不着急引入 IoC,而是用 Go 标准库注册一个最普通的 HTTP handler。
代码如下:
func main() {
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("hello from net/http\n"))
})
gs.Run()
}使用下面的命令运行示例:
cd examples/02-stdlib-http
go run .然后访问新增的 /hello 路由:
curl http://127.0.0.1:9090/hello这次不再是 404 了,而是返回预期中的响应:
hello from net/http
可以看到,应用已经从“只能启动”变成了“能处理 HTTP 请求”。 不过 handler 目前还是一个匿名函数,业务状态和配置都写不进去。
在上一章中,/hello 是直接写在 main 函数里的匿名函数。
它能验证 HTTP 处理有效,但不好继续扩展。
因为一旦问候语、目标用户、校验规则需要变成配置,匿名函数就会显得很别扭。
所以本章会创建一个业务对象 GreetingRoot,让它持有配置,并且把它的方法实现成 handler。
代码如下:
type GreetingRoot struct {
Greeting string `value:"${demo.greeting:=Hello}" expr:"$ != ''"`
Audience string `value:"${demo.audience:=Go-Spring}" expr:"$ != ''"`
}
func (g *GreetingRoot) Hello(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "%s, %s!\n", g.Greeting, g.Audience)
}
func main() {
root := &GreetingRoot{}
http.HandleFunc("/hello", root.Hello)
gs.Configure(func(app gs.App) {
app.Root(root)
}).Run()
}与上一章相比,这次的代码有两个实质性变化:
- handler 不再是匿名函数,而是
GreetingRoot.Hello方法,业务状态进入了结构体。 root被传给了app.Root(root),所以 Go-Spring 会在启动过程中处理它的字段标签。
GreetingRoot 字段上的 value tag 表示配置绑定关系:
${demo.greeting:=Hello}表示读取配置项demo.greeting的值,如果没有在任何地方配置,就使用默认值Hello。expr:"$ != ''"表示绑定后的值不能为空,如果不满足条件,应用就会在启动阶段失败,而不是等到请求进来才暴露问题。
使用下面的命令运行示例:
cd examples/03-configure-root-bean
go run .然后访问 /hello 路由:
curl http://127.0.0.1:9090/hello我们会得到预期中的响应:
Hello, Go-Spring!
这里的 Hello 和 Go-Spring 都来自字段 tag 中的默认值。
也就是说,尽管应用仍然使用标准库路由,但业务对象已经进入 Go-Spring 的配置绑定流程了。
上一章咱们已经把配置绑定关系写进了 GreetingRoot,但运行结果还完全依赖 tag 里的默认值。
真实应用一般不会只靠默认值运行,环境之间的差异常常会放在配置文件、环境变量或启动参数里。
这一章仍然沿用上一章的代码,代码没有任何变化,只是在示例目录中增加一个配置文件。
GreetingRoot 仍然绑定同样的两个配置项:
type GreetingRoot struct {
Greeting string `value:"${demo.greeting:=Hello}" expr:"$ != ''"`
Audience string `value:"${demo.audience:=Go-Spring}" expr:"$ != ''"`
}但在 ./conf 目录下新增一个配置文件 app.properties,内容如下:
demo.greeting=Hello from ./conf/app.properties
demo.audience=config file使用下面的命令运行示例:
cd examples/04-config-overrides
go run .然后访问 /hello 路由:
curl http://127.0.0.1:9090/hello这时响应会从默认值变成配置文件里的值:
Hello from ./conf/app.properties, config file!
不改变配置文件的内容,咱们可以用环境变量覆盖其中一个配置项,
比如 GS_DEMO_AUDIENCE,它会映射成 demo.audience:
GS_DEMO_AUDIENCE="env var" go run .
curl http://127.0.0.1:9090/hello执行上面的命令,会看到响应从配置文件里的值变成了环境变量里的值:
Hello from ./conf/app.properties, env var!
咱们还可以使用命令行参数覆盖配置,写法是 -Dkey=value:
go run . -Ddemo.audience="cmd arg"
curl http://127.0.0.1:9090/hello执行上面的命令,会看到响应从配置文件里的值变成了命令行参数里的值:
Hello from ./conf/app.properties, cmd arg!
这一章咱们没有改变任何代码,就让上一章的配置绑定变得更方便运维了。
不过 GreetingRoot 虽然已经可以被 Go-Spring 绑定配置了,但它还是在 main 中手动创建的。
前面几章咱们一直把对象创建和路由注册写在 main 函数里,
虽然适合入门,但是当请求日志、耗时统计、请求 ID、panic recovery 这类横切逻辑出现时,
HTTP 入口就不应该散落在 main 中了。
因此这一章咱们使用 Go-Spring 来构造所有的组件。
首先咱们把配置收拢成一个配置结构体。
注意这里的 tag 不再写 demo.greeting,而是写 greeting 和 audience,
因为注册构造函数时会指定整体前缀 ${demo}。
代码如下:
type GreetingConfig struct {
Greeting string `value:"${greeting:=Hello}" expr:"$ != ''"`
Audience string `value:"${audience:=Go-Spring}" expr:"$ != ''"`
}
type Controller struct {
cfg GreetingConfig
}
func NewController(cfg GreetingConfig) *Controller {
return &Controller{cfg: cfg}
}
func (c *Controller) Hello(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "%s, %s!\n", c.cfg.Greeting, c.cfg.Audience)
}然后咱们显式创建一个 *gs.HttpServeMux。
它的内部仍然使用标准库 http.NewServeMux(),只是最终返回给 Go-Spring 的是带中间件的 handler。
代码如下:
func NewHTTPMux(c *Controller) *gs.HttpServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/hello", c.Hello)
return &gs.HttpServeMux{Handler: logging(mux)}
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("method=%s path=%s elapsed=%s",
r.Method, r.URL.Path, time.Since(start))
})
}这次,咱们在代码中添加了一个 logging 中间件,可以记录请求的方法、路径和耗时。
最后咱们把构造函数注册给容器:
func init() {
gs.Provide(NewController, gs.TagArg("${demo}"))
gs.Provide(NewHTTPMux)
}
func main() {
gs.Run()
}使用下面的命令运行示例:
cd examples/05-http-middleware-mux
go run .然后访问 /hello 路由:
curl -i http://127.0.0.1:9090/hello可以看到这次响应变成了下面这样:
Hello with middleware, custom mux!
同时,控制台上还会打印请求的方法、路径和耗时,说明请求确实经过了 logging 中间件。
这一章咱们完成了一个重要转折:
main 又回到了只负责 gs.Run(),对象创建、配置绑定、HTTP mux 组装则统统交给容器。
上一章咱们已经用容器创建了 controller 和 HTTP mux,但问候语仍然由 controller 自己拼出来。
随着业务增长,controller 应该更专注于 HTTP 请求和响应,业务逻辑和规则应该放进 service。
因此这一章咱们新增一个 GreetingService,让 controller 通过构造函数依赖 service。
代码如下:
type GreetingService struct {
Greeting string `value:"${demo.greeting:=Hello}" expr:"$ != ''"`
}
func NewGreetingService() *GreetingService {
return &GreetingService{}
}
func (s *GreetingService) Message(audience string) string {
return fmt.Sprintf("%s, %s!", s.Greeting, audience)
}
type Controller struct {
service *GreetingService
Audience string `value:"${demo.audience:=Go-Spring}" expr:"$ != ''"`
}
func NewController(service *GreetingService) *Controller {
return &Controller{service: service}
}
func (c *Controller) Hello(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, c.service.Message(c.Audience))
}注册代码也不复杂,只需要多提供一个构造函数:
func init() {
gs.Provide(NewGreetingService)
gs.Provide(NewController)
gs.Provide(NewHTTPMux)
}使用下面的命令运行示例:
cd examples/06-multi-bean-di
go run .然后访问 /hello 路由:
curl http://127.0.0.1:9090/hello可以看到预期中的响应:
Hello from service, controller config!
这一章咱们展示的是构造函数注入。
NewController 的参数声明了它需要 *GreetingService,
Go-Spring 就会先创建 service,再把它传给 controller。
业务代码不需要自己查找依赖,也不需要在 main 中手工组装对象图。
上一章的 service 内部只有一个字段,但真实服务通常还会依赖 Redis、数据库、消息队列等外部客户端。
为了让示例聚焦在 Go-Spring 的注册方式上,本章用一个轻量的 RedisClient 来模拟外部客户端:
它会读取配置并打印日志,但不会连接真实的 Redis。
首先定义 Redis 配置和客户端构造函数:
type RedisConfig struct {
Addr string `value:"${addr}" expr:"$ != ''"`
Password string `value:"${password:=}"`
}
type RedisClient struct {
cfg RedisConfig
}
func NewRedisClient(cfg RedisConfig) (*RedisClient, error) {
log.Printf("create redis client addr=%s", cfg.Addr)
return &RedisClient{cfg: cfg}, nil
}
func CloseRedis(*RedisClient) error {
return nil
}
func (c *RedisClient) Ping(context.Context) error {
log.Printf("redis ping addr=%s", c.cfg.Addr)
return nil
}然后让 service 依赖 *RedisClient,并在处理请求时调用它:
type GreetingService struct {
redis *RedisClient
Greeting string `value:"${demo.greeting:=Hello}" expr:"$ != ''"`
}
func NewGreetingService(redis *RedisClient) *GreetingService {
return &GreetingService{redis: redis}
}
func (s *GreetingService) Message(ctx context.Context, audience string) string {
_ = s.redis.Ping(ctx)
return fmt.Sprintf("%s, %s!", s.Greeting, audience)
}最后咱们需要增加 Redis client 的注册代码:
func init() {
gs.Provide(NewRedisClient, gs.TagArg("${spring.go-redis}")).Destroy(CloseRedis)
gs.Provide(NewGreetingService)
gs.Provide(NewController)
gs.Provide(NewHTTPMux)
}注册 Redis client 时:
gs.TagArg("${spring.go-redis}")表示构造函数参数RedisConfig从spring.go-redis前缀读取配置;Destroy(CloseRedis)表示容器关闭时调用销毁函数。
咱们还需要在配置文件中增加一个配置项,用于指定 Redis 地址:
spring.go-redis.addr=127.0.0.1:6379使用下面的命令运行示例:
cd examples/07-redis-single-client
go run .会看到控制台上打印了创建客户端的日志:
create redis client addr=127.0.0.1:6379
访问 /hello 路由:
curl http://127.0.0.1:9090/hello可以看到预期中的响应:
Hello with Redis, single client!
另外,咱们还能在控制台上看到请求过程中打印出的 redis ping 日志。
虽然这一章的 Redis Client 只是模拟对象,但它的注册方式和真实客户端没有区别:
配置绑定、依赖注入、资源销毁都交给容器。
上一章咱们注册的只有一个 Redis client,所以 service 直接依赖 *RedisClient 就够了。
但真实应用里更常见的是同一种客户端有多个实例,例如默认 Redis、cache Redis、queue Redis。
如果咱们继续手写多个 NewRedisClient,那么注册很快就会变乱,所以这一章咱们引入条件注册、命名 bean 和配置分组。
首先在默认客户端注册的时候增加两个声明:
gs.Provide(NewRedisClient, gs.TagArg("${spring.go-redis}")).
Condition(gs.OnProperty("spring.go-redis.addr")).
Destroy(CloseRedis).
Name("__default__")Condition(gs.OnProperty("spring.go-redis.addr"))表示只有配置里存在spring.go-redis.addr时才创建默认客户端。Name("__default__")表示给这个 bean 一个名字,后续同类型实例变多时,注入方就可以明确选择它。
然后注册其他 Redis 实例,不过咱们不需要一条条手写注册,而是交给 gs.Group,
它可以根据配置批量创建多个同类型的实例:
gs.Group("${spring.go-redis.instances}", NewRedisClient, CloseRedis)咱们需要在配置文件中新增一个 spring.go-redis.instances 配置项,它是一个 map,
键是实例名称,值是实例的配置。
spring.go-redis.addr=127.0.0.1:6379
spring.go-redis.instances.cache.addr=127.0.0.1:6380
spring.go-redis.instances.queue.addr=127.0.0.1:6381现在需要对 service 做一些调整,因为现在同类型 *RedisClient 有多个注册实例。
咱们可以通过它字段上的 autowire 指定注入名为 __default__ 的实例:
type GreetingService struct {
Client *RedisClient `autowire:"__default__?"`
Greeting string `value:"${demo.greeting:=Hello}" expr:"$ != ''"`
}使用下面的命令运行示例:
cd examples/08-conditional-multi-redis
go run .然后会看到启动时控制台上打印了 __default__ 创建的日志,
但是并没有 cache 和 queue 创建的日志。
这是因为 Go-Spring 是按需实例化的,用不到的实例不会被创建。
访问 /hello 路由:
curl http://127.0.0.1:9090/hello可以看到预期中的响应:
Hello with conditional Redis, conditional clients!
咱们可以修改 service,让它注入 cache 实例:
type GreetingService struct {
Client *RedisClient `autowire:"cache?"`
Greeting string `value:"${demo.greeting:=Hello}" expr:"$ != ''"`
}然后会看到启动时只有 cache 实例被创建。
同样的方式,咱们也可以注入 queue 实例。
这一章咱们解决的是“同类型多个实例如何管理”的问题。
Condition 可以控制 bean 是否创建,
Name 可以给 bean 命名,
autowire 可以让依赖方选择具体实例,
Group 可以把一组配置批量转换成一组客户端。
到目前为止,示例已经展示了 HTTP、配置、依赖注入和客户端注册,但日志还只是普通文本。 真实服务需要更容易检索和关联的日志:业务日志要能标识来源,请求日志要能记录方法、路径和耗时, 同一次请求中的日志最好带上同一个 request id,等等。 所以这一章咱们引入 Go-Spring 的日志系统。
首先注册两个日志标签,一个用于业务日志,一个用于 HTTP 访问日志:
var (
tagBizGreeting = log.RegisterBizTag("greeting", "serve")
tagHTTPRequest = log.RegisterRPCTag("http", "request")
)service 中不再使用标准库打印日志,而是使用 Go-Spring 的日志系统记录结构化字段:
func (s *GreetingService) Summary(ctx context.Context) string {
log.Info(ctx, tagBizGreeting,
log.String("greeting", s.Greeting),
log.Msg("building greeting"),
)
return s.Greeting + ", structured logs!"
}为 HTTP 入口新增一个中间件 requestID,它可以从请求头读取或生成 request id。
对于 request id 这类信息,咱们希望它们能被自动记录到日志中,而不是每次打印日志时手动添加。
所以,咱们把 request id 放进 context 中,方便日志系统自动提取。
type requestIDKey struct{}
func NewHTTPMux(c *Controller) *gs.HttpServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/hello", c.Hello)
return &gs.HttpServeMux{Handler: requestID(logging(mux))}
}
func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = fmt.Sprintf("%d", time.Now().UnixNano())
}
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}咱们需要给日志系统设置一个上下文提取回调 log.FieldsFromContext,
这样它就可以从 context 中自动提取 request id,然后和其他字段一起被记录下来。
log.FieldsFromContext = func(ctx context.Context) []log.Field {
id, ok := ctx.Value(requestIDKey{}).(string)
if !ok || id == "" {
return nil
}
return []log.Field{log.String("request_id", id)}
}完整代码在 examples/09-logging/main.go。
最后咱们在配置文件中添加日志系统的配置,将日志以 JSON 格式输出到控制台。
logging.logger.root.type=ConsoleLogger
logging.logger.root.level=INFO
logging.logger.root.layout.type=JSONLayout
logging.logger.root.layout.fileLineMaxLength=30使用下面的命令运行示例:
cd examples/09-logging
go run .带上请求 ID 访问 /hello 路由:
curl -H "X-Request-ID: demo-1" http://127.0.0.1:9090/hello可以看到预期中的响应:
Hello with logging, structured logs!
同时可以看到控制台会输出 JSON 日志,包含 tag、request_id、HTTP 方法、路径、耗时和业务字段。
{"level":"info","time":"2026-05-03T08:57:21.525","fileLine":"...mples/09-logging/main.go:29","tag":"_biz_greeting_serve","request_id":"demo-1","greeting":"Hello with logging","msg":"building greeting"}
{"level":"info","time":"2026-05-03T08:57:21.526","fileLine":"...mples/09-logging/main.go:80","tag":"_rpc_http_request","request_id":"demo-1","method":"GET","path":"/hello","elapsed":"783.125µs","msg":"http request completed"}
这一章的重点不是“打印更多内容”,而是让日志变成结构化事件: 标签说明事件类型,字段承载可检索数据,context 把一次请求中的公共字段串起来。
经过前面的步骤,应用已经具备了一个 Web 服务常见的核心结构: HTTP 入口、controller/service 分层、配置绑定、外部客户端和结构化日志。 还剩最后一个问题:测试。
如果 service 直接依赖具体 Redis client,测试时就会很难替换; 如果测试必须启动真实的 HTTP Server,也会让反馈变慢。 这一章咱们把依赖改成接口,并使用 Go-Spring 的测试容器验证装配关系。
第一处变化是定义接口,让 service 依赖行为而不是具体实现:
type RedisPinger interface {
Ping(context.Context) error
}
type GreetingService struct {
redis RedisPinger
Greeting string `value:"${demo.greeting:=Hello}" expr:"$ != ''"`
}
func NewGreetingService(redis RedisPinger) *GreetingService {
return &GreetingService{redis: redis}
}生产环境咱们仍然使用 RedisClient,不过这次注册时还需要把它导出为 RedisPinger:
gs.Provide(NewRedisClient, gs.TagArg("${spring.go-redis}")).
Condition(gs.OnProperty("spring.go-redis.addr")).
Destroy(CloseRedis).
Export(gs.As[RedisPinger]())这样一来,咱们就可以在测试代码里用一个很小的 fakeRedis 来替代真实 Redis:
type fakeRedis struct {
err error
calls int
}
func (f *fakeRedis) Ping(context.Context) error {
f.calls++
return f.err
}有了这个 fakeRedis,service 就可以直接测试了:
func TestGreetingServiceWithFakeRedis(t *testing.T) {
redis := &fakeRedis{}
service := &GreetingService{redis: redis, Greeting: "Hi"}
got := service.Message(context.Background(), "tester")
if got != "Hi, tester!" {
t.Fatalf("unexpected greeting: %q", got)
}
if redis.calls != 1 {
t.Fatalf("expected one redis ping, got %d", redis.calls)
}
}对于 controller,咱们也可以不启动真实的 HTTP Server,而是使用 httptest 来测试 handler:
func TestControllerWithFakeRedis(t *testing.T) {
service := &GreetingService{redis: &fakeRedis{}, Greeting: "Hi"}
controller := &Controller{service: service, Audience: "controller"}
req := httptest.NewRequest(http.MethodGet, "/hello", nil)
rec := httptest.NewRecorder()
controller.Hello(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
if strings.TrimSpace(rec.Body.String()) != "Hi, controller!" {
t.Fatalf("unexpected body: %q", rec.Body.String())
}
}上面都是非常纯粹的 Go 原生单元测试,并没有依赖 Go-Spring 容器。 如果咱们还想验证 Go-Spring 容器里的装配关系,可以按照下面的步骤进行。
- 首先使用
gs.Web(false)关闭真实的 HTTP Server, - 然后使用
app.Provide(&fakeRedis{}).Export(...)把 fakeRedis 注册成接口实现, - 最后可以在
gs.RunTest()中注入要检查的对象。
代码如下:
func TestIoCContainerWithFakeRedis(t *testing.T) {
gs.Web(false).Configure(func(app gs.App) {
app.Property("spring.app.config.dir", "./testdata/empty-conf")
// The built-in Redis client is not enabled
app.Provide(&fakeRedis{}).Export(gs.As[RedisPinger]())
}).RunTest(t, func(ts *struct {
Service *GreetingService `autowire:""`
Controller *Controller `autowire:""`
}) {
if ts.Service == nil {
t.Fatal("service was not injected")
}
if ts.Controller == nil {
t.Fatal("controller was not injected")
}
got := ts.Service.Message(context.Background(), "ioc")
if got != "Hello, ioc!" {
t.Fatalf("unexpected ioc greeting: %q", got)
}
})
}gs.RunTest() 在运行的时候会启动完整的 Go-Spring 容器,对 init 注册的对象进行装配。
它接受一个回调函数,回调函数的参数是一个结构体,用来注入要检查的对象,
可以使用 autowire 和 value 标签来注入对象或者配置。
完整代码在 examples/10-unit-tests/main.go,
测试代码在 examples/10-unit-tests/main_test.go。
使用下面的命令运行测试:
cd examples/10-unit-tests
go test可以看到,所有测试都通过了。
这一章把前面所有的能力都落到可测试性上。
接口让外部依赖可以被 fakeRedis 替换,Export(gs.As[...]) 让生产实现按接口进入容器,
gs.Web(false) 和 gs.RunTest() 让容器装配本身也能被测试。
至此,一个 Go-Spring 应用从最小启动、HTTP 路由、配置绑定、容器装配、外部客户端、条件多实例、 结构化日志到测试的完整路径就串起来了。
从上面十个示例可以看到,Go-Spring 的核心价值并不是要替代 Go 生态中已有的标准库和工具, 而是提供了一套非常工程化的组织方式: 把应用启动、配置绑定、对象装配、资源生命周期、日志和测试这些横向能力组织起来。
对于很小的程序,直接使用标准库可能已经足够; 但当服务规模继续增长时,这些容器和生命周期能力会逐渐体现出价值。