10 个示例快速入门 Go-Spring|v1.3.0 正式发布
如今,只需简简单单的一行gs.Run(),便可以优雅地启动整个 Go-Spring 应用,实在令人畅快!
从此,我们只需要依照既定规范,对配置、日志与启动进行扩展,便能够从容应对各种日常开发场景,
既能保持代码简洁高效,也为系统留下足够的可扩展空间。
接下来,本文将通过 10 个示例,带你一步一步、由浅入深地快速入门 Go-Spring。
每个示例都能独立运行,完整代码在 https://github.com/lvan100/go-spring-first 这里。
1. 启动一个最小 Go-Spring 应用
第一步咱们先不写业务代码,只来确认 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 requestsready to serve requests表示应用已经启动,并且成功监听:9090。
使用下面的命令访问根路径:
curl http://127.0.0.1:9090/会得到:
404 page not found这里 404 是预期结果。它说明 HTTP Server 已经启动了,只是还没有 handler 能处理这个路径。
按下Ctrl+C可以停止程序,接着 Go-Spring 进入关闭流程。
2. 添加一个标准库 HTTP 路由
上一章的应用已经能够启动,但是还没有业务入口,所以任何请求都会返回 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() }完整代码在 examples/02-stdlib-http/main.go。
使用下面的命令运行示例:
cd examples/02-stdlib-http go run .然后访问新增的/hello路由:
curl http://127.0.0.1:9090/hello这次不再是 404 了,而是返回预期中的响应:
hello from net/http可以看到,应用已经从“只能启动”变成了“能处理 HTTP 请求”。
不过 handler 目前还是一个匿名函数,业务状态和配置都写不进去。
3. 把业务对象注册为 root bean
在上一章中,/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() }完整代码在 examples/03-configure-root-bean/main.go。
与上一章相比,这次的代码有两个实质性变化:
- handler 不再是匿名函数,而是
GreetingRoot.Hello方法,业务状态进入了结构体。 root被传给了app.Root(root),所以 Go-Spring 会在启动过程中处理它的字段标签。
GreetingRoot 字段上的valuetag 表示配置绑定关系:
${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 的配置绑定流程了。
4. 用外部配置覆盖默认值
上一章咱们已经把配置绑定关系写进了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完整代码在 examples/04-config-overrides/main.go。
使用下面的命令运行示例:
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中手动创建的。
5. 用容器装配 HTTP mux 和中间件
前面几章咱们一直把对象创建和路由注册写在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() }完整代码在 examples/05-http-middleware-mux/main.go。
使用下面的命令运行示例:
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 组装则统统交给容器。
6. 把 controller 和 service 拆成多个 bean
上一章咱们已经用容器创建了 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) }完整代码在 examples/06-multi-bean-di/main.go。
使用下面的命令运行示例:
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中手工组装对象图。
7. 注册一个外部客户端 bean
上一章的 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)表示容器关闭时调用销毁函数。
完整代码在 examples/07-redis-single-client/main.go。
咱们还需要在配置文件中增加一个配置项,用于指定 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 只是模拟对象,但它的注册方式和真实客户端没有区别:
配置绑定、依赖注入、资源销毁都交给容器。
8. 条件注册和多实例客户端
上一章咱们注册的只有一个 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:"$ != ''"` }完整代码在 examples/08-conditional-multi-redis/main.go。
使用下面的命令运行示例:
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可以把一组配置批量转换成一组客户端。
9. 接入结构化日志
到目前为止,示例已经展示了 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)} }完
最后咱们在配置文件中添加日志系统的配置,将日志以 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 把一次请求中的公共字段串起来。
10. 让组件可以脱离真实服务测试
经过前面的步骤,应用已经具备了一个 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 生态中已有的标准库和工具,