IoC 容器
控制反转(Inversion of Control,IoC)和依赖注入(Dependency Injection,DI)是 Java Spring 框架的核心基础。 Go-Spring 继承了 Java Spring 的设计理念,同时保持 Go 语言的原生风格,为 Go 开发者提供了一个简洁高效的依赖注入框架。
什么是依赖注入?
如果你熟悉 Java Spring,可以直接跳过这一节。
依赖注入是一种设计模式,它能帮助我们写出更优雅、更易维护的代码:
- 解耦组件依赖:组件不需要自己创建依赖对象,而是由容器统一提供
- 集中生命周期管理:对象的创建、初始化、销毁都由容器统一管理
- 更方便单元测试:测试时可以轻松将依赖替换为 Mock 对象
- 更好的可维护性:依赖关系清晰可见,集中管理而非分散在代码各处
我们通过代码直观感受一下其中的区别。不使用依赖注入时,我们通常在构造函数中自己创建依赖:
type UserController struct {
service *UserService
}
// ❌ 不使用 DI:UserController 必须自己创建 UserService,这就是紧耦合
func NewUserController() *UserController {
return &UserController{
// UserService 被硬编码写死在这里,无法在创建时灵活替换
service: NewUserService(),
}
}使用依赖注入后,我们只需要在构造函数中声明需要什么依赖,不需要自己创建:
type UserController struct {
service UserService
}
// ✅ 使用 DI:UserService 由外部传入,其实现可以灵活替换,这就是松耦合
func NewUserController(service UserService) *UserController {
return &UserController{service: service}
}为什么需要 IoC 容器?
上面的方式已经实现了解耦,但还有一个问题需要解决:谁来负责创建这些依赖并传入呢?
当应用只有两三个对象时,我们可以手动组装:
service := NewUserService()
controller := NewUserController(service)但是当应用有几十上百个组件,并且它们之间存在复杂的依赖关系时,继续逐一手动组装会变得非常繁琐。 这时候 IoC 容器就派上用场了 —— 它可以帮助我们自动完成以下事情:
- 我们将所有组件注册到容器中(这一步叫做注册 Bean)
- 容器自动分析组件之间的依赖关系
- 容器按照正确的顺序创建所有对象
- 容器自动把依赖注入到需要的地方
- 容器全程管理组件从创建到销毁的整个生命周期
一句话概括:所有组件的组装和管理工作,都可以交给 IoC 容器自动完成,而让我们更专注于编写业务逻辑。 这,就是 Go-Spring 的使命!
快速开始
让我们通过一个简洁的示例快速了解 Go-Spring 的用法:
package main
import (
"fmt"
"net/http"
// 引入 Go-Spring 核心包
"go-spring.org/spring/gs"
)
// UserService 业务服务,整个应用只需要创建一个实例
type UserService struct{}
// GetUser 获取用户名
func (s *UserService) GetUser() string {
return "Alice"
}
// UserController HTTP 控制器,依赖 UserService
type UserController struct {
service *UserService
}
// NewUserController 构造函数,参数即依赖声明
// 可以由容器自动分析参数类型并注入匹配的 Bean
func NewUserController(service *UserService) *UserController {
return &UserController{service: service}
}
// Hello 处理 /hello 请求
func (c *UserController) Hello(w http.ResponseWriter, r *http.Request) {
user := c.service.GetUser()
fmt.Fprintf(w, "Hello, %s!", user)
fmt.Println("Hello,", user)
}
// init 在程序启动前执行,向容器注册所有的 Bean
func init() {
// 注册 UserService,可被其他 Bean 依赖注入
gs.Provide(new(UserService))
// 注册 UserController,容器自动解析构造函数参数并注入依赖
gs.Provide(NewUserController)
// 注册 HTTP 路由配置,支持函数式 Bean 定义
// 返回的 HttpServeMux 会被容器识别并自动启动 HTTP 服务
gs.Provide(func(c *UserController) *gs.HttpServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/hello", c.Hello)
return &gs.HttpServeMux{Handler: mux}
})
}
func main() {
// 启动 Go-Spring 应用
// 自动完成所有 Bean 的创建和依赖注入,启动 HTTP 服务
gs.Run()
}使用 go run main.go 运行应用,然后访问 http://localhost:9090/hello,你会看到:
Hello, Alice!同时控制台也会输出:
Hello, Alice可以看到整个过程非常简洁:只需要在 init() 中注册服务、控制器和路由,通过构造函数声明依赖, 然后调用 gs.Run() 启动应用,依赖注入、对象管理和 HTTP 服务启动就都由 Go-Spring 自动完成。
Bean 定义
Bean 是一种由容器统一管理的组件,其创建、初始化及销毁等生命周期过程均由容器负责。 这一概念源自 Java Spring,但在 Go-Spring 中被重新诠释,以更贴合 Go 的设计特点。
从依赖注入的角度看,Bean 可以理解为一个可被容器管理和组合的最小功能单元。 这一理解并非简单迁移,而是对 Go 代码组织方式的自然延伸。
在 Go 中,包主要用于代码组织与命名空间划分,粒度相对较粗, 一个包内往往包含多个在运行时具有不同依赖关系的功能单元, 而语言本身并未提供对这些单元的统一管理机制。
因此,Go-Spring 将 Bean 引入为运行时的组织单元,对包形成补充: 它将更细粒度的功能抽象为可管理的对象,用于更方便、准确地描述依赖关系与生命周期。
Bean 注入
Go-Spring 的依赖注入体系可以从两个维度来理解: 一是注入方式,即通过不同的语法形式来声明依赖关系; 二是注入目标,即依赖最终以何种形态被接收和使用。
注入方式
Go-Spring 支持两种注入方式:结构体字段注入和构造函数参数注入。
结构体字段注入
结构体字段注入是最简单、最直观的注入方式: 只需要在结构体字段上通过 autowire(或者 inject)标签声明依赖, 容器就会自动把匹配到的 Bean 注入到该字段中。
示例:
// UserController 依赖 UserService
type UserController struct {
Service UserService `autowire:""`
}这种方式简洁明了,不需要手动编写构造函数,适用于依赖关系较简单的业务场景。
构造函数参数注入
构造函数参数注入是指依赖通过构造函数的参数传入, 容器在创建 Bean 时自动解析这些参数并提供对应的依赖。
示例:
// UserController 依赖 UserService
type UserController struct {
service UserService
}
// 依赖通过构造函数参数传入
func NewUserController(service UserService) *UserController {
return &UserController{service: service}
}
func init() {
// 注册构造函数,容器自动分析参数并注入对应的依赖
gs.Provide(NewUserController)
}Go-Spring 使用标准的 Go 构造函数进行依赖注入, 无需额外的框架级约定或者特殊的语法标记。
选择哪种注入方式
从解耦的角度看,大多数场景下更推荐使用构造函数注入, 因为它能够在对象创建阶段就明确地声明依赖关系,使组件的边界更加清晰, 也更有利于保证依赖的不可变性与可测试性。
对于依赖关系比较简单或者以便捷性为优先的场景,也可以使用结构体字段注入, 在表达清晰性与开发效率之间取得平衡,而不必过度限制其使用。
注入目标
无论是结构体字段注入还是构造函数参数注入,Go-Spring 都支持将 Bean 注入到多种类型的目标中。
最常见的方式是注入单个 Bean,即根据类型或名称匹配到唯一的 Bean 实例进行注入。
同时,也支持注入多个 Bean,这种情况下,容器会自动将所有符合条件的 Bean 收集并注入到切片或 Map 中。
注入单个 Bean
这是最基础、也是最常见的使用方式,即注入唯一符合条件的 Bean。 绝大多数业务依赖都属于这种场景。
按类型注入
我们可以在结构体字段上添加 autowire 或者 inject 标签, 容器就会自动把按照类型匹配到的 Bean 注入到该字段中。
type Service struct {
// 按类型自动匹配,注入唯一的 UserRepository 接口
Repo UserRepository `autowire:""`
}我们也可以在注册构造函数 Bean 时,通过 TagArg 来显式指定注入所使用的 Bean。 容器会根据 TagArg 的内容进行匹配,并将对应的 Bean 注入到构造函数参数中。
// UserController 需要注入一个 UserService
func NewUserController(service UserService) *UserController {
return &UserController{service: service}
}
func init() {
// 按类型自动匹配,此处可以省略 TagArg 的内容
gs.Provide(NewUserController, gs.TagArg(""))
}按名称注入
当候选 Bean 只有一个时,我们可以直接按照类型进行注入,无需指定名称。 但当候选 Bean 有多个时,就需要通过名称进行区分,以便明确指定具体的注入目标。
func init() {
// 注册 master 和 slave 两个命名 Bean
gs.Provide(NewMasterDataSource).Name("master")
gs.Provide(NewSlaveDataSource).Name("slave")
}
type Service struct {
// 在结构体字段中注入名为 "slave" 的 Bean
ds DataSource `autowire:"slave"`
}func init() {
gs.Provide(NewMasterDataSource).Name("master")
gs.Provide(NewSlaveDataSource).Name("slave")
}
func NewRepository(ds *DataSource) *Repository {
return &Repository{ds: ds}
}
func init() {
// 在构造函数参数中注入名称为 "slave" 的 Bean
gs.Provide(NewRepository, gs.TagArg("slave"))
}可空注入
默认情况下,容器在找不到匹配的 Bean 时,会报错并终止启动。 如果希望在没有找到 Bean 时不抛出错误,而是注入对应的零值,我们可以使用 ? 标记,表示将该依赖声明为可空。
type Service struct {
// 可空注入,找不到匹配 Bean 时不报错,保持零值
OptionalDep Dep `autowire:"?"`
// 同时指定名称 + 可空选项
NamedOptional Dep `autowire:"my-name?"`
}// UserController 需要注入一个 UserService
func NewUserController(service *UserService) *UserController {
return &UserController{service: service}
}
func init() {
// 构造函数参数使用可空注入,当 Bean 不存在时注入零值
gs.Provide(NewUserController, gs.TagArg("?"))
// 同时指定名称 + 可空选项
gs.Provide(NewUserController, gs.TagArg("my-name?"))
}注入多个 Bean
当需要获取多个符合条件的 Bean 时, 我们可以将依赖声明为 []T(切片)或者 map[string]T(Map)。 这时,容器会自动将所有符合条件的同类型 Bean 收集并注入到对应的集合中。
切片收集 []T
我们可以在结构体字段中使用切片来收集多个 Bean, 也可以在构造函数参数中通过切片来收集多个 Bean。
type Application struct {
// 收集所有的 Plugin 实现到切片
plugins []Plugin `autowire:""`
}
func init() {
// 将多个实现注册为 Plugin 类型的 Bean
gs.Provide(NewPluginA).Export(gs.As[Plugin]())
gs.Provide(NewPluginB).Export(gs.As[Plugin]())
gs.Provide(NewPluginC).Export(gs.As[Plugin]())
}func NewApplication(plugins []Plugin) *Application {
return &Application{plugins: plugins}
}
func init() {
// 注入所有 Plugin 到切片,这里省略了 TagArg 参数
gs.Provide(NewApplication)
}在未指定 tag 内容时,容器会按照 Bean 名称的字典序 对切片中的元素进行排序, 确保每次收集到的结果都是确定的,从而保证行为也是一致的。
如果需要精确控制顺序,我们可以在 tag 中通过 Bean 名称列表来显式指定排序规则。
type Chain struct {
// 顺序: auth(可空) -> tracing -> recovery
Filters []Filter `autowire:"auth?,tracing,recovery"`
}func NewChain(filters []Filter) *Chain {
return &Chain{filters: filters}
}
func init() {
// 顺序: auth(可空) -> tracing -> recovery
gs.Provide(NewChain, gs.TagArg("auth?,tracing,recovery"))
}我们可以对列表中的每个 Bean 使用可空语法 name?,表示当对应 Bean 不存在时自动跳过。
此外,我们还可以使用通配符 * 来表示包含所有未显式列出的剩余 Bean。 但需要注意,通配符 * 在同一个表达式中只能出现一次。
当使用通配符 * 时,Bean 的收集顺序如下:
*之前的显式 Bean 按 tag 中声明的顺序排列;*匹配的剩余 Bean 按名称字典序排列;*之后的显式 Bean 按 tag 中声明的顺序排列。
例如,autowire:"a,*,c?,b" 表示: 先收集 a,然后收集除 c、b 之外的其他所有 Bean(按名称排序), 接着是 c(若存在),最后是 b。其中 c 可以为空,不存在时会自动跳过。
Map 收集 map[string]T
除了使用切片收集多个 Bean,我们也可以使用 map[string]T 来进行收集。 两者的 tag 语法基本一致,但 map[string]T 的结果不保证顺序(因为 map 本身是无序的)。
在这种形式下,map[string]T 的 key 表示 Bean 的名称,value 则是对应的 Bean 实例。
type Router struct {
// name -> Handler 映射,key 就是 Bean 名称
Handlers map[string]Handler `autowire:""`
}// 注入所有 Handler 到 Map
func NewRouter(handlers map[string]Handler) *Router {
return &Router{handlers: handlers}
}
func init() {
gs.Provide(NewRouter)
}对于 Map 收集我们也可以使用可空语法 name?,表示当对应的 Bean 不存在时自动跳过。 不过,通配符 * 虽然语法上可用,但在 Map 场景下意义相对有限,因为 Map 本身不保证顺序, 即使使用了 * 也无法体现收集顺序上的控制。
type Service struct {
// 只包含指定名称的处理器
Handlers map[string]Handler `autowire:"auth,user?,order"`
}func NewService(handlers map[string]Handler) *Service {
return &Service{Handlers: handlers}
}
func init() {
gs.Provide(NewService, gs.TagArg("auth,user?,order"))
}通过配置项注入
通常情况下,我们在注入时就可以确定使用的 Bean 名称; 但在某些场景中,也可能需要根据配置项动态决定具体注入哪个(些) Bean。 Go-Spring 支持这种用法,只需要将 Bean 名称写为 ${...} 形式的配置项表达式, 容器会在运行时从配置中解析出对应的 Bean 名称(列表)并完成注入。
这种方式适用于各种注入形式: 无论是结构体字段注入还是构造函数参数注入, 无论是单个 Bean 注入还是切片、Map 等集合收集, 都可以通过 ${...} 表达式动态指定 Bean。
type Service struct {
// 从配置项 "storage.provider" 读取 Bean 名称
// 这样就可以通过配置动态切换实现,而且无需修改代码
Storage Storage `autowire:"${storage.provider}"`
}func NewService(storage Storage) *MyService {
return &MyService{storage: storage}
}
func init() {
// 从配置项 "storage.provider" 读取 Bean 名称
gs.Provide(NewService, gs.TagArg("${storage.provider}"))
}type Chain struct {
// 从配置项 "http.filters" 读取过滤器列表
// 这样就可以通过配置动态切换实现,而且无需修改代码
Filters []Filter `autowire:"${http.filters}"`
}func NewChain(filters []Filter) *Chain {
return &Chain{filters: filters}
}
func init() {
// 从配置项 "http.filters" 读取过滤器列表
gs.Provide(NewChain, gs.TagArg("${http.filters}"))
}${...} 语法本身也支持指定默认值;由于在 Bean 名称选择中较少使用,这里不展开介绍。
延迟注入
延迟注入主要用于解决某些场景下的循环依赖问题,并且仅适用于结构体字段注入。 其用法是在 tag 中添加 ,lazy 标记。
type Service struct {
// 强制这个字段延迟注入,等所有非延迟注入完成后再处理
Dep Dependency `autowire:",lazy"`
}需要注意的是,标记为 lazy 的字段会在所有非延迟注入完成之后再统一处理。 由于这是一个独立阶段,因此在前一阶段的注入过程中,这些字段始终保持为空,请记住不要使用它们。
Bean 类型
Go-Spring 在注册 Bean 时,支持三种形式的参数:
- 结构体指针:将预先创建好的对象直接交由容器管理,使用方式最简单
- 构造函数:由容器在启动时调用构造函数创建 Bean,更推荐这种方式
- 函数指针:将函数本身作为 Bean 注册,支持函数式风格的使用场景
结构体指针
这是最简单的注册方式,直接传入一个已经创建好的结构体指针即可。 该对象既可以是临时创建的实例,也可以是全局创建并在其他地方复用的对象。
// MyService 是你的业务结构体
type MyService struct {
// ...
}
func init() {
// 直接 new 出对象指针,注册到容器
gs.Provide(new(MyService))
}当我们将一个全局创建并被复用的对象注册到容器中时,容器仍然会对其进行管理, 包括在适当的生命周期阶段调用初始化和销毁方法。 这种能力在 Go-Spring 与其他框架或既有系统进行集成时尤为有用。
构造函数
通过构造函数创建 Bean 是 Go-Spring 推荐的方式。 在这种模式下,所有依赖都通过构造函数参数传入,由容器负责解析并提供。
type MyService struct {
dep Dep
}
// 构造函数参数接收依赖,返回实例
func NewMyService(dep Dep) *MyService {
return &MyService{dep: dep}
}
func init() {
gs.Provide(NewMyService)
}上面的构造函数形式是 func(...) T,它直接返回 Bean,适用于创建过程确定不会失败的场景。
但在创建复杂对象时,初始化过程可能会失败,例如配置校验不通过、文件打开失败或数据库连接失败等情况。 此时我们可以通过构造函数返回 error 来表达失败,即使用 func(...) (T, error) 的形式。 容器会自动识别该模式,并在创建失败时终止启动。
// 构造函数返回 error,用于表示 Bean 创建是否成功
func NewMyService(dep Dep) (*MyService, error) {
return &MyService{dep: dep}, nil
}
func init() {
gs.Provide(NewMyService)
}参数绑定
在前面的示例中,我们已经展示了 TagArg 的一些用法,本节将进一步解释其背后的实现原理。
由于 Go 语言本身只支持在结构体字段上使用 tag,而无法直接在构造函数参数上声明 tag, 因此 Go-Spring 采用了一种替代方案: 在注册阶段通过 Arg 参数,为需要特殊处理的构造函数参数显式提供绑定信息。
这种在注册阶段将构造函数参数与具体依赖建立映射关系的机制,被称为参数绑定(Arg binding)。
Go-Spring 提供了多种 Arg 实现,用于不同类型的参数绑定场景:
TagArg:用于绑定 Bean 依赖或配置属性ValueArg:用于绑定固定值BindArg:用于绑定 Option 模式构造函数参数IndexArg:用于按参数索引位置进行绑定
注入 Bean
当一个构造函数 Bean 依赖其他 Bean 时,可以通过 gs.TagArg 来显式声明依赖关系。
func NewUserController(service *UserService) *UserController {
return &UserController{service: service}
}
func init() {
// TagArg 的参数为空字符串表示仅按类型匹配,不需要名称限定
gs.Provide(NewUserController, gs.TagArg(""))
}TagArg 的字符串参数与结构体字段中的 autowire tag 在语义上是等价的。
另外,在上面的示例中,因为只有一个参数,而且按类型匹配就行,不需要限定名称, 所以我们可以省略 TagArg 参数。
func NewUserController(service *UserService) *UserController {
return &UserController{service: service}
}
func init() {
// 按类型自动匹配,此时可以省略 TagArg
gs.Provide(NewUserController)
}因为在前面章节的示例中已经展示了较多 TagArg 的用法, 例如按名称注入 Bean、通过配置项动态指定 Bean 名称,以及在切片和 Map 中进行收集等, 因此这里不再重复展开。相关细节可以参考 Bean 注入章节。
注入配置项
TagArg 不仅可以用于绑定 Bean 依赖注入,也可以用于绑定配置属性。 Go-Spring 支持从配置系统读取配置项的值,并自动转换为对应类型后注入到构造函数参数中。
type RedisClient struct {
host string
port int
}
// 构造函数参数直接注入配置值
func NewRedisClient(host string, port int) *RedisClient {
return &RedisClient{host: host, port: port}
}
func init() {
// 使用 TagArg 指定配置项路径
gs.Provide(NewRedisClient,
gs.TagArg("${redis.host:=localhost}"), // 从配置项 "redis.host" 读取主机地址
gs.TagArg("${redis.port:=6379}"), // 从配置项 "redis.port" 读取端口
)
}我们可以通过如下 YAML 配置来完成 RedisClient 的注入:
redis:
host: localhost
port: 6379我们也可以让容器将整个配置对象直接绑定到一个结构体参数中。
// RedisConfig 定义 Redis 配置结构
type RedisConfig struct {
Host string `value:"${host:=localhost}"`
Port int `value:"${port:=6379}"`
Password string `value:"${password:=}"`
DB int `value:"${db:=0}"`
Timeout time.Duration `value:"${timeout:=5s}"`
}
// 直接注入绑定好的 RedisConfig 对象
func NewRedisClient(cfg RedisConfig) *RedisClient {
return &RedisClient{
host: cfg.Host,
port: cfg.Port,
// ...
}
}
func init() {
// 前缀 "redis." 会自动添加到所有字段
// 所以 cfg.Host 对应配置键 "redis.host"
gs.Provide(NewRedisClient, gs.TagArg("redis"))
}这种方式可以将一组相关配置项集中起来统一管理,比分散注入多个参数更清晰整洁。
TagArg 还支持更丰富的配置项绑定能力,例如 Map 和列表注入、配置引用、类型转换器以及嵌套配置等。 更多细节可以参考 01-configuration.md。
注入固定值
如果参数的值在注册 Bean 时已经确定,既不需要从容器获取,也不需要从配置系统读取, 那么我们可以使用 ValueArg 来绑定一个固定值。
type RedisClient struct {
db int
}
func NewRedisClient(db int) *RedisClient {
return &RedisClient{db: db}
}
func init() {
// db 参数绑定固定值 0
gs.Provide(NewRedisClient, gs.ValueArg(0))
}Option 绑定
Functional Options 是 Go 语言中非常流行的一种编程模式, 主要用于处理构造函数中的可选参数问题。
例如,一个服务可能包含多个配置项,但其中大部分都有合理的默认值,调用者通常只需要修改少数几个配置。 在这种情况下,Option 模式相比于定义多个构造函数或传入一长串零值参数,会更加清晰和优雅。
下面来看一个典型的 Option 模式定义:
// Option 定义用于修改 Server 配置的函数类型
type Option func(*Server)
// WithPort 返回一个用于设置端口的 Option
func WithPort(port int) Option {
return func(s *Server) {
s.port = port
}
}
// WithTimeout 返回一个用于设置超时的 Option
func WithTimeout(timeout time.Duration) Option {
return func(s *Server) {
s.timeout = timeout
}
}
type Server struct {
port int
timeout time.Duration
}
// NewServer 接受可变数量的 Option,用于配置 Server
func NewServer(opts ...Option) *Server {
s := &Server{
port: 8080, // 默认端口
timeout: 30 * time.Second, // 默认超时
}
// 依次应用所有 Option
for _, opt := range opts {
opt(s)
}
return s
}上面的代码展示了标准的 Functional Options 模式写法:
- 定义
Option函数类型,用于接收*Server并修改其配置 - 通过
WithXXX函数创建具体的 Option,并设置对应配置项 NewServer接受可变参数opts ...Option,在创建实例后依次应用这些 Option
那么问题来了:如果 Option 本身的创建依赖配置或其他 Bean,该如何处理? 例如 WithPort 需要的端口来自配置文件,而不是在代码中写死。
在这种场景下,Go-Spring 的 BindArg 可以很好地解决这个问题: 它允许将每个 Option 的创建过程交由容器管理,在创建 Option 时自动注入所需依赖或配置, 最终再将生成好的 Option 传入 NewServer 构造函数中。
func init() {
// 使用 BindArg 为每个 Option 提供参数绑定
// WithPort 需要从配置读取端口,用 TagArg 绑定
// WithTimeout 使用固定超时 60 秒,用 ValueArg 绑定
gs.Provide(NewServer,
gs.BindArg(WithPort, gs.TagArg("${server.port:=8080}")),
gs.BindArg(WithTimeout, gs.ValueArg(60*time.Second)),
)
}此外,BindArg 还支持条件控制: 当条件满足时,对应的 Option 才会被创建并传入构造函数; 如果条件不成立,则该位置会被直接跳过,不会传入任何参数。
func init() {
gs.Provide(
NewServer,
// 只有配置了 server.port 才添加这个 Option
gs.BindArg(WithPort, gs.TagArg("${server.port}")).
Condition(gs.OnProperty("server.port")),
gs.BindArg(WithTimeout, gs.ValueArg(60*time.Second)),
)
}在上面的例子中,只有当配置系统中显式设置了 server.port 时, WithPort 这个 Option 才会被创建并注入到构造函数中; 如果未配置,则会跳过该 Option,直接使用 NewServer 中定义的默认值。
这一机制在实际应用中非常有用,可以根据配置条件选择性地启用或关闭某些功能,实现更加灵活的装配方式。
参数顺序
默认情况下,gs.Provide() 中的参数绑定是按照顺序匹配构造函数参数的: 第一个绑定对应第一个参数,第二个绑定对应第二个参数,以此类推。
但在构造函数参数较多、仅需对少量参数进行显式绑定,其余参数可以交由容器按类型自动推断的场景下, 我们可以使用 IndexArg 来显式指定绑定位置,而不必按照参数顺序逐一传入。
// 构造函数有三个参数:a, b, c
func NewBean(a *ServiceA, b *ServiceB, c string) *Bean {
return &Bean{a: a, b: b, c: c}
}
func init() {
// 仅为第三个参数(index=2,从 0 开始计数)绑定固定值
// a 和 b 将由容器按类型自动推断并注入
gs.Provide(NewBean, gs.IndexArg(2, gs.ValueArg("custom-value")))
}通过这种方式,在不改变构造函数参数顺序的前提下,我们既可以精确绑定关键参数, 又可以将其余参数交由容器自动推断,从而使整体配置更加灵活、也更易于维护。
函数指针
除了结构体指针和构造函数,Go-Spring 还支持直接将函数指针注册为 Bean, 适用于函数本身作为可注入组件的场景。
由于在 Go 的类型系统中,函数类型无法与构造函数明确区分, 而容器又需要显式识别该函数是否作为 Bean 本身进行注册, 因此在注册 Bean 时需要使用 reflect.ValueOf 对函数进行封装。
import "reflect"
// PasswordChecker 定义密码校验函数的类型
type PasswordChecker func(username, password string) bool
// Authenticator 需要注入密码校验函数
type Authenticator struct {
checker PasswordChecker `autowire:""`
}
func NewAuthenticator(checker PasswordChecker) *Authenticator {
return &Authenticator{checker: checker}
}
// 提供一个具体的密码校验函数
func BcryptPasswordChecker(username, password string) bool {
// ... 具体的校验逻辑
return true
}
func init() {
// 使用 reflect.ValueOf() 封装函数指针,直接注册为 Bean
// 这样任何需要 PasswordChecker 的组件都能注入这个函数
gs.Provide(reflect.ValueOf(BcryptPasswordChecker))
// 注册 Authenticator,它会自动注入上面的密码校验函数
gs.Provide(NewAuthenticator)
}Bean 配置
在注册 Bean 的时候,我们通常还需要进行一些额外配置, 例如自定义名称、指定初始化方法、导出接口、附加条件或声明依赖等。
Go-Spring 提供了链式调用的方式,使我们能够更加方便地完成这些 Bean 配置。
设置 Bean 名称
为了唯一标识容器中的每个 Bean,Go-Spring 使用类型 + 名称的组合来生成唯一标识符。
如果在注册 Bean 的时候没有显式指定名称,Go-Spring 会自动使用类型的简短名称作为默认名称。例如:
- 对于结构体指针
*UserService,默认名称为"UserService" - 对于接口
UserService,默认名称为"UserService"
当同一个类型需要注册多个不同实例时(例如主库与从库两个数据源), 我们可以通过 .Name() 方法为每个 Bean 显式指定名称,以示区分。
func init() {
// 同一个 DataSource 类型,注册两个不同名称的 Bean
gs.Provide(NewMasterDataSource).Name("master")
gs.Provide(NewSlaveDataSource).Name("slave")
}在设置了名称之后,我们就可以通过该名称来明确指定所使用的实例了。
type UserRepo struct {
// 结构体字段注入在 autowire tag 中指定 Bean 名称
ds *DataSource `autowire:"slave"`
}
func NewUserRepo(ds *DataSource) *UserRepo {
return &UserRepo{ds: ds}
}
func init() {
// 构造函数参数注入使用 TagArg 指定 Bean 名称
gs.Provide(NewUserRepo, gs.TagArg("slave"))
}设置生命周期回调
有时候,在 Bean 创建完成并且完成所有依赖注入之后,我们还需要执行一些自定义的初始化逻辑; 同样,在容器退出的时候,也需要对资源进行优雅释放。
为了解决这类问题,Go-Spring 提供了生命周期回调机制。
其中,初始化回调发生在 Bean 完成创建并且依赖注入结束之后,用于执行自定义初始化逻辑, 例如建立数据库连接、加载缓存数据到内存或校验配置的正确性等。
而销毁回调则发生在容器退出阶段,用于资源的优雅释放, 例如关闭数据库连接、持久化内存状态或停止后台任务等。
Go-Spring 支持两种配置生命周期回调的方式:通过函数指针显式指定,或通过方法名进行声明。
方式一:通过函数指针设置
我们可以直接传入一个独立的函数指针作为生命周期回调,该函数需要接收 Bean 实例作为参数。 它可以没有返回值,也可以仅返回 error。
init 和 destroy 的函数签名规则是完全一致的。
type MyService struct {
client *redis.Client
}
func NewMyService() *MyService {
return &MyService{}
}
// 初始化函数,接收 bean 作为参数
func InitMyService(s *MyService) error {
s.client = redis.NewClient( /* ... */ )
// 测试连接
if err := s.client.Ping().Err(); err != nil {
return err // 初始化失败,容器终止启动
}
return nil
}
// 销毁函数,接收 bean 作为参数
func DestroyMyService(s *MyService) error {
return s.client.Close()
}
func init() {
gs.Provide(NewMyService).
Init(InitMyService). // 设置初始化函数
Destroy(DestroyMyService) // 设置销毁函数
}如果 init 回调返回错误,容器会终止启动,表示初始化失败。 如果 destroy 回调返回错误,容器会记录该错误日志,但不会阻塞容器退出。
方式二:通过方法名指定
如果结构体本身已经定义了初始化方法和销毁方法,且函数签名符合要求, 那么我们可以通过指定方法名的方式来配置生命周期回调。
type MyService struct {
client *redis.Client
}
func NewMyService() *MyService {
return &MyService{}
}
// Init 初始化方法,在依赖注入完成后调用
func (s *MyService) Init() error {
s.client = redis.NewClient( /* ... */ )
// 测试连接
if err := s.client.Ping().Err(); err != nil {
return err // 初始化失败,容器终止启动
}
return nil
}
// Destroy 销毁方法,在容器退出时调用
func (s *MyService) Destroy() error {
return s.client.Close()
}
func init() {
gs.Provide(NewMyService).
InitMethod("Init"). // 设置初始化方法
DestroyMethod("Destroy") // 设置销毁方法
}导出为接口
在 Go 程序中,我们经常会使用接口。那么在 Go-Spring 中,如何注册并使用接口类型呢?
Go-Spring 的做法是:在注册 Bean 时,需要显式指定该 Bean 要导出的接口类型。 这样做是因为在 Go 中,一个结构体可能实现多个接口,甚至可能在无意中实现了某些接口。 所以为了避免自动推导带来的不确定性,需要明确声明对外暴露的接口类型。
// 定义接口
type UserService interface {
Get(id int) (*User, error)
}
// 具体实现
type UserServiceImpl struct {
// ...
}
func NewUserServiceImpl() *UserServiceImpl {
return &UserServiceImpl{}
}
func (s *UserServiceImpl) Get(id int) (*User, error) {
return &User{ID: id}, nil
}
func init() {
// 将 UserServiceImpl 导出为 UserService 接口,供依赖方按接口注入
gs.Provide(NewUserServiceImpl).Export(gs.As[UserService]())
}在上面的示例中,容器中会同时存在两个 Bean:一个是原始类型的 Bean,另一个是以接口形式导出的 Bean。 这两种形式都可以被注入,具体使用哪一个取决于注入点的类型声明。
当然,我们也可以直接在构造函数中返回接口类型,这样就不需要显式导出为接口了。
// 定义接口
type UserService interface {
Get(id int) (*User, error)
}
// 具体实现
type userServiceImpl struct {
// ...
}
func NewUserService() UserService {
return &userServiceImpl{}
}
func (s *userServiceImpl) Get(id int) (*User, error) {
return &User{ID: id}, nil
}
func init() {
// 构造函数直接返回接口类型,容器会按接口类型进行注册,因此无需再显式导出
gs.Provide(NewUserService)
}附加激活条件
有时候,我们需要根据环境变量、配置文件等条件,在特定情况下才注册 Bean。 此时可以通过 .Condition() 方法为 Bean 添加条件,使其仅在满足条件时才生效。
func init() {
// 仅在 dev profile 下注册该 Bean
gs.Provide(NewDevLogger).Condition(
gs.OnProperty("spring.profiles.active"). // 监听 spring.profiles.active 配置
HavingValue("expr:contains($, 'dev')"). // 属性值需包含 dev
MatchIfMissing(), // 属性不存在时默认匹配
)
}由于条件注册涉及的内容较多,本文后续会在条件注册章节专门介绍。
显式依赖声明
绝大多数情况下,Go-Spring 会通过注入关系自动推断依赖顺序—— 你注入了哪个 Bean,容器就会保证该 Bean 先完成初始化。
但在某些情况下,两个 Bean 之间可能没有直接的依赖注入关系,却仍然需要控制它们的初始化顺序。 此时我们可以使用 .DependsOn() 方法显式声明依赖关系,以确保被依赖的 Bean 先完成初始化。
.DependsOn() 的作用是告诉容器: 虽然当前 Bean 没有直接注入该依赖,但在初始化顺序上必须先完成它的创建。
type A struct {
// ...
}
type B struct {
// ...
}
func init() {
// 声明 B 依赖 A,确保 A 在 B 之前完成初始化
gs.Provide(NewB).DependsOn(gs.BeanIDFor[A]())
}在上面的例子中,B 虽然没有直接依赖 A,但通过 .DependsOn() 方法, 可以确保 A 先于 B 完成初始化。
如果这两个间接关联的 Bean 都定义了销毁方法,那么容器在退出时也会按照相反的顺序执行销毁流程, 先销毁依赖的 Bean,再销毁被依赖的 Bean。
标记为根 Bean
Go-Spring 采用按需创建的机制,默认情况下只有被标记为 root 的 Bean 才会作为依赖注入的入口被触发。
但在与其他框架集成时,有时并不需要使用 Go-Spring 自带的 Runner 或 Server(它们是内置的 root Bean), 而只是将其作为一个独立的 Bean 容器来使用。
此时,如果没有显式指定 root Bean,容器将缺少初始化入口,从而无法自动触发用户注册 Bean 的实例化与依赖注入流程。
在这种情况下,可以通过 app.Root() 方法将某个 Bean 显式标记为根 Bean。 这样,Go-Spring 容器就会以该 Bean 为起点,沿依赖关系递归完成其自身及其依赖 Bean 的初始化与注入。
func main() {
bootstrap := &Bootstrap{}
// ...
// 在中间的代码中使用 bootstrap 对象
// ...
gs.Configure(func(app gs.App) {
// 将已有的 Bootstrap 实例显式注册为 Root Bean
// 容器会以它为入口,确保该对象一定被创建并纳入依赖体系
app.Root(bootstrap)
}).Run()
}在上面的例子中,bootstrap 被标记为根 Bean, 即使在 IoC 容器外创建,也会被纳入容器管理并作为初始化入口使用。
Bean 注册
Go-Spring 提供了多种 Bean 注册 API,以满足不同的使用场景。
通过 gs.Provide() 注册
我们可以在包的 init() 函数中调用 gs.Provide() 注册 Bean,这是最基础、最常用的方式。 但是需要注意,该方法必须在应用启动前调用,否则会直接 panic。
func init() {
gs.Provide(NewUserService)
}gs.Provide() 会将 Bean 记录到全局注册表中,并在应用启动时统一合并。
对于绝大多数业务组件而言,这是推荐的注册方式。
通过 gs.Module() 注册
gs.Provide() 一次只能注册一个 Bean,而在实际场景中,我们也常常需要批量注册 Bean。 为此,Go-Spring 提供了 gs.Module() 用于统一组织和注册多个 Bean。
gs.Module() 同时支持条件化注册能力,可以根据配置项条件决定是否启用整个模块。
从本质上看,Module 是一组具备条件控制能力的 Bean 注册单元,非常适合用于按需启用功能模块, 因此它是 Starter 机制的完美抽象,第三方集成包通常通过 Module 对外暴露能力。
在实现上,gs.Module() 通过回调函数提供 gs.BeanProvider, 开发者可以通过其 Provide(...) 方法注册 Bean,其用法与 gs.Provide() 完全一致。
例如,在下面的模块中,只有当配置项 enable.redis=true 时,才会注册 Redis 相关 Bean; 否则整个 Redis 模块将不会生效。
func RedisModule(r gs.BeanProvider, p flatten.Storage) error {
var m map[string]RedisConfig
// 从配置中绑定 Redis 实例配置(name -> config)
if err := conf.Bind(p, &m); err != nil {
return err
}
// 为每一个 Redis 配置注册一个独立的 Redis Client Bean
for name, config := range m {
r.Provide(NewRedisClient, gs.ValueArg(config)).Name(name)
}
return nil
}
func init() {
gs.Module(
// 当 enable.redis=true 时启用该模块,否则模块不生效
gs.OnProperty("enable.redis").HavingValue("true"),
RedisModule,
)
}通过 gs.Group() 注册
gs.Group() 是 gs.Module() 的一种特殊封装,用于批量创建同类型的 Bean。 它以配置 key 作为 Bean 名称,非常适合通过配置字典生成多个实例。
当需要基于配置创建多个同类型 Bean 时, 例如多个 HTTP 客户端、多个数据源或多个缓存客户端(每个实例都拥有独立的配置参数), 使用 gs.Group() 可以显著减少模板代码。
// 定义 HTTP 客户端配置结构
type HTTPClientConfig struct {
BaseURL string `value:"${baseURL}"`
Timeout time.Duration `value:"${timeout:=30s}"`
}
// 根据配置创建 HTTP 客户端
func NewHTTPClient(c HTTPClientConfig) (*http.Client, error) {
return &http.Client{Timeout: c.Timeout}, nil
}
func init() {
// 从配置 "http.clients" 读取 map
// key 作为 Bean 名称,value 作为配置参数
// 每个 entry 对应调用 NewHTTPClient 创建一个独立 Bean
gs.Group("${http.clients}", NewHTTPClient, nil)
}对应的 YAML 配置如下:
http:
clients:
serviceA: # 作为 Bean 名称
baseURL: "http://a.example.com"
timeout: 30s
serviceB: # 作为 Bean 名称
baseURL: "http://b.example.com"
timeout: 60s在上面的示例中,gs.Group() 处理完成后,容器中会生成两个 *http.Client Bean, 名称分别为 serviceA 和 serviceB,我们可以在服务中按名称注入使用。
type MyService struct {
ClientA *http.Client `autowire:"serviceA"`
ClientB *http.Client `autowire:"serviceB"`
}如果需要释放资源,还可以提供销毁函数,该函数会对每个 Bean 实例分别生效。
func init() {
gs.Group("${http.clients}",
NewHTTPClient,
// 容器在销毁阶段会对每个实例分别调用销毁函数
func(c *http.Client) error { return c.Close() },
)
}通过 Configuration 注册
Configuration 模式允许一个配置类(父 Bean)导出多个子 Bean, 用于将同一功能领域的 Bean 进行集中组织管理。 导出的子 Bean 可以直接复用配置类中已注入的配置参数。
例如,一个数据库配置类可以统一导出数据源、Repository 以及 Mapper 等相关 Bean。
// DatabaseConfiguration 是配置类(父 Bean),本身支持依赖注入
type DatabaseConfiguration struct {
MaxOpenConns int `value:"${db.max-open-conns:=10}"`
}
// 导出 DataSource Bean
// 可使用配置类(父 Bean)中已注入的参数
func (c *DatabaseConfiguration) NewDataSource() *DataSource {
return NewDataSource(c.MaxOpenConns)
}
// 导出 UserRepository Bean
// 方法参数参与依赖注入(仅基于类型匹配)
func (c *DatabaseConfiguration) NewUserRepository(ds *DataSource) *UserRepository {
return NewUserRepository(ds)
}
// 导出 OrderRepository Bean
// 方法参数参与依赖注入(仅基于类型匹配)
func (c *DatabaseConfiguration) NewOrderRepository(ds *DataSource) *OrderRepository {
return NewOrderRepository(ds)
}
func init() {
// 注册配置类
// 启用 Configuration 后,容器会自动扫描该对象的方法
// 并将符合规则的方法返回值注册为 Bean
gs.Provide(new(DatabaseConfiguration)).Configuration()
}在上面的示例中,定义了一个 DatabaseConfiguration 作为配置类(父 Bean), 用于集中管理数据库相关的配置参数。 随后通过方法分别导出 DataSource、UserRepository 和 OrderRepository 这些 Bean。 这些 Bean 既可以使用配置类中已注入的参数,也可以接收其他 Bean 作为依赖(仅支持基于类型匹配)。
你可能会好奇 Configuration 模式是如何工作的:
- 首先将配置类作为普通 Bean 注册到容器中,并通过
.Configuration()方法将其标记为配置类 - 在解析阶段,容器遍历所有已注册 Bean,筛选出启用
Configuration模式的 Bean - 随后扫描该配置类的所有公开方法,并根据
Includes和Excludes规则进行过滤 - 对于符合条件的方法,容器会将其返回值自动注册为独立的子 Bean,并加入 Bean 列表
- 最终,所有 Bean(无论来源)都会纳入统一的生命周期与规则管理
实际上,这些符合条件的方法被视为构造函数,因此既可以只返回一个对象,也可以额外返回一个 error。
下面是一些符合条件的方法示例:
// ✅ 符合:返回指针,无 error
func (c *Config) NewDataSource() *DataSource
// ✅ 符合:返回指针 + error
func (c *Config) NewDataSource() (*DataSource, error)
// ❌ 不符合:返回值个数不对
func (c *Config) NewDataSource() (*DataSource, string, error)如果需要自定义包含和排除规则,我们可以通过 Includes 和 Excludes 参数进行控制。 默认情况下,Includes 仅匹配方法名符合 New.* 的方法(即以 New 开头), 而 Excludes 不排除任何方法。
自定义包含和排除规则示例:
func init() {
gs.Provide(new(DatabaseConfiguration)).
Configuration(gs.Configuration{
Includes: []string{"New.*", "Create.*"}, // 包含匹配这些正则的方法
Excludes: []string{".*Internal$"}, // 排除匹配这些正则的方法
})
}通过 app.Provide() 注册
除了前面几种注册方式外,我们还可以通过 gs.Configure() 提供的回调函数注册 Bean。 该方法会在回调中提供一个 gs.App 对象,开发者可以通过其 Provide 方法进行 Bean 注册。
func main() {
gs.Configure(func(app gs.App) {
// 可以在回调中注册 Bean 或者设置应用级属性
// 这些内容仅在当前应用实例中生效
app.Provide(NewAppSpecificComponent)
app.Property("server.port", "8080")
}).Run()
}这种注册 Bean 的方式通常用于单元测试中,可以实现测试之间的数据隔离。
条件注册
有时候我们希望某些 Bean 仅在满足特定条件时才生效,这可以通过 Condition 机制来实现。
Go-Spring 提供了丰富的条件实现及组合工具,开箱即用。 我们既可以基于配置属性进行判断,也可以根据 Bean 是否存在进行判断, 还能通过 And / Or / Not / None 组合多个条件,实现更复杂的判断逻辑。
前面已经提到过了,在注册 Bean 的时候,我们可以通过 .Condition() 方法来为 Bean 绑定条件。
gs.Provide(NewMyService).Condition(gs.OnProperty("my.condition"))常用条件
Go-Spring 提供了一些常用的条件类型。
属性条件
OnProperty 是最常用的条件类型之一, 既可以根据配置属性是否存在进行判断, 也可以根据配置属性的值是否等于特定值进行判断, 还支持通过 expr 表达式实现更灵活的条件判断。
// 配置项存在即可满足条件(既可以是叶子节点,也可以是路径节点)
gs.OnProperty("enable.redis")
// 当配置项等于指定值时才满足条件
gs.OnProperty("env").HavingValue("prod")
// 配置项不存在时也满足条件(MatchIfMissing)
gs.OnProperty("optional.feature").MatchIfMissing()OnProperty 还支持表达式判断,只需要在值前添加 expr: 前缀, 即可实现除存在性判断之外的更复杂校验逻辑。
// 匹配端口大于 8080 的情况
gs.OnProperty("server.port").HavingValue("expr:$ > 8080")表达式语法
OnProperty 使用 expr-lang/expr 引擎进行计算, 语法简洁直观:
$:表示当前配置属性的值,所有表达式均围绕$进行比较判断- 比较运算符:支持
>、<、>=、<=、==、!=等常见比较操作 - 逻辑运算符:支持
&&、||、!用于逻辑组合 - 字符串操作:支持
contains、hasPrefix、hasSuffix等字符串操作方法
常见表达式示例:
// 端口大于 1024 且小于 65535
gs.OnProperty("server.port").HavingValue("expr: $ > 1024 && $ < 65535")
// 环境不是生产环境
gs.OnProperty("app.env").HavingValue("expr: $ != 'prod'")
// 配置项以 "http://" 开头
gs.OnProperty("app.base-url").HavingValue("expr: startsWith($, 'http://')")
// 配置项包含指定关键字
gs.OnProperty("app.features").HavingValue("expr: contains($, 'debug')")我们可以通过 gs.RegisterExpressFunc() 注册自定义函数,并在表达式中使用。
func init() {
// 注册自定义表达式函数
gs.RegisterExpressFunc("isValidPort", func(port int) bool {
return port > 1024 && port < 65535
})
// 在条件表达式中使用自定义函数
gs.Provide(NewServer).Condition(
gs.OnProperty("server.port").HavingValue("expr:isValidPort($)"),
)
}Bean 存在条件
Go-Spring 提供了一些用于根据容器中是否存在特定 Bean 来进行判断的条件:
// 当容器中存在 UserService(至少一个匹配)时满足条件
gs.OnBean[*UserService]()
// 当容器中不存在 UserService 时满足条件
gs.OnMissingBean[*UserService]()
// 当容器中恰好存在一个 UserService 时满足条件
gs.OnSingleBean[*UserService]()
// 按名称匹配,判断指定名称的 DataSource 是否存在
gs.OnBean[*DataSource]("master")OnBean[T]()表示容器中至少存在一个匹配的 Bean。OnMissingBean[T]()表示容器中不存在匹配的 Bean。OnSingleBean[T]()表示容器中恰好存在一个匹配的 Bean。
我们在使用这三个条件的时候,可以选择传入 Bean 名称或者不传入。 不传 Bean 名称时表示仅按类型匹配,传入 Bean 名称时表示同时按类型和名称进行匹配。
自定义函数条件
在一些简单情况下,我们可以使用 OnFunc 包装自定义函数来实现条件判断。
gs.OnFunc(func(ctx gs.ConditionContext) (bool, error) {
// 自定义任意条件判断逻辑
return myCustomCheck(ctx)
})组合条件
Go-Spring 提供了四种条件逻辑组合:And / Or / Not / None。
// 当需要同时满足所有条件时,使用 gs.And
gs.Provide(NewService).Condition(gs.And(
gs.OnProperty("enable.service"),
gs.OnBean[Config](),
))
// 当需要任一条件满足时,使用 gs.Or
gs.Provide(NewService).Condition(gs.Or(
gs.OnProperty("profile.dev"),
gs.OnProperty("profile.test"),
))
// 当需要条件取反时,使用 gs.Not
gs.Provide(NewFallbackService).Condition(gs.Not(
gs.OnBean[RealService](),
))
// 当所有条件都不满足时,使用 gs.None
gs.Provide(NewService).Condition(gs.None(
gs.OnProperty("profile.dev"),
gs.OnProperty("profile.test"),
))gs.And:要求所有子条件同时满足时才成立gs.Or:只要任一子条件满足即可成立gs.Not:对子条件结果取反gs.None:所有子条件均不满足时才成立
这些组合条件还可以相互嵌套使用,实现更复杂的条件逻辑。
// 生产环境 且 (启用了 A 或 启用了 B)
gs.And(
gs.OnProperty("env").HavingValue("prod"),
gs.Or(
gs.OnProperty("enable.a"),
gs.OnProperty("enable.b"),
),
)需要注意的是,虽然组合条件可以实现复杂的逻辑,但一般不建议使用过于复杂的条件表达式。 如果逻辑过于复杂,应当优先考虑是否可以进行简化或重构方案。
缓存条件结果 OnOnce
有时候条件计算可能较为复杂,并且需要在多处复用。 为了避免重复计算,我们可以使用 OnOnce 对结果进行缓存,后续判断将直接复用缓存结果。
// 条件只会计算一次,后续直接复用缓存结果
gs.Provide(NewService).Condition(gs.OnOnce(
gs.OnProperty("enable.service"),
gs.OnBean[Config](),
))绝大多数情况下,简单条件不需要缓存结果; 只有在条件较为复杂且需要在多处复用时,才需要使用缓存机制。
Profile 条件
对于按环境(开发/测试/生产)选择性启用 Bean 的场景, Go-Spring 提供了 .OnProfiles() 链式方法,相比显式条件设置更加简洁直观。
func init() {
// 仅在 dev 环境下启用该 Bean
gs.Provide(NewDevLogger).OnProfiles("dev")
}本质上,Profile 条件是基于 spring.profiles.active 配置项进行判断的。 当设置的 profiles 与当前激活的 profiles 中有任意一个匹配时,条件即可满足。
spring.profiles.active表示当前激活的 profiles,例如"dev"、"test"或"prod"。
容器原理
本章节介绍 Go-Spring 的一些实现细节。 了解容器的执行原理,有助于我们更好地理解和使用 Go-Spring。
运行流程
Go-Spring 最核心的是 IoC 容器的运行流程。 下面介绍容器从启动到关闭的完整过程。
注册阶段
Bean 的注册分为 全局注册 和 容器注册 两个独立阶段, 其根本目的在于支持单元测试中的数据隔离。
全局注册
我们在 init() 中调用 gs.Provide()、gs.Module() 完成 Bean 注册, 这些注册信息会被保存到全局注册表中,并作为所有容器首要的注册来源。
在创建容器时,Go-Spring 会从全局注册表中拷贝一份注册信息生成独立的容器实例, 因此每个容器之间互不影响。
这也是单元测试能够实现数据隔离的基础: 每个测试用例都会创建独立容器,并基于全局注册表的拷贝构建自己的 Bean 集合。
容器注册
容器注册主要用于解决单元测试场景下的 Bean 补充问题。 Go-Spring 支持在不启动完整应用的情况下运行单测, 此时可以通过 app.Provide() 直接向当前容器注册所需的 Bean, 以补齐测试环境中的依赖,这些 Bean 仅在当前容器中生效。
解析阶段
容器启动后,第一步是对所有 Bean 进行合并与解析处理,该阶段按顺序执行以下几个步骤:
Bean 合并
第一步是将所有来源的 Bean 统一合并到一起,包括:
- 通过
app.Provide()注册的 Bean - 通过
gs.Provide()注册的 Bean - 通过
gs.Module()或gs.Group()注册的 Bean - 通过
Configuration模式导出的 Bean
合并完成后,容器会得到一份完整的待处理 Bean 列表。
条件裁剪
这一步会遍历所有 Bean,并依次执行其 Condition 判断。 最终,满足条件的 Bean 被保留,不满足条件的 Bean 被剔除, 只有保留下来的 Bean 才会参与后续的创建过程。
通过这一过程,实现了“根据条件动态决定哪些 Bean 生效”的能力,也是多环境配置的基础。
冲突检测
在 Go-Spring 中,类型与名称完全一致的 Bean 被视为冲突。 Go-Spring 严格遵循不允许覆盖的原则,并在解析阶段的最后进行冲突检测。 一旦检测到冲突,容器将直接启动失败并报错。
注入阶段
解析完成后,容器会从 root beans 出发,按照依赖关系递归创建 Bean 实例并完成依赖注入。 这一阶段的核心步骤如下:
- 首先基于类型和名称为所有 Bean 建立索引,用于后续快速匹配依赖
- 从 root beans 开始,按照依赖顺序递归创建 Bean 并完成注入。 在此过程中,每个 Bean 的处理流程如下:
- 创建 Bean 实例后立即进行依赖注入
- 注入过程中检测循环依赖,如果存在则直接报错
- 同时记录需要延迟注入的字段,供后续统一处理
- 记录 Bean 之间的 destroy 执行顺序依赖关系
- 注入完成后调用 Bean 的
init方法完成初始化
- 所有 Bean 处理完成后,统一处理延迟注入字段,完成最终依赖绑定
- 对 destroy 方法依赖关系进行拓扑排序,确保被依赖的 Bean 优先销毁
- 清理解析阶段的临时元数据,释放不必要的内存资源
注入阶段完成后,所有 Bean 均已创建并完成依赖注入与初始化,容器进入运行阶段。
运行阶段
Go-Spring 在运行阶段不参与业务逻辑处理,也不支持在运行时动态获取 Bean 实例。
这一设计是有意为之的。 一方面,从 Java Spring 的实现来看,支持运行时获取 Bean 会显著增加框架实现复杂度; 另一方面,Go 语言更强调静态明确的依赖关系,这种设计可以避免运行时反射带来的不确定性。 因此,Go-Spring 在设计上刻意避免了运行时的反射式 Bean 访问能力。
换句话说,Go-Spring 采用的是启动期模型(startup-time model): 即所有依赖关系仅在启动阶段完成注入,注入完成后容器不再参与运行过程。
这一设计也带来了一些额外优势,例如:
- 所有依赖错误都能在启动阶段暴露,避免运行时才发现问题
- 更适合资源受限环境,例如嵌入式系统或者轻量级服务
- 运行时无需容器参与,整体执行更轻量、可预测性更强
关闭阶段
当应用收到退出信号(例如 SIGINT)时,会通知容器进入关闭阶段。 此时容器会按照依赖关系的逆序销毁所有 Bean,确保依赖安全释放。
销毁过程中会依次调用各 Bean 的 Destroy 回调方法用于释放资源, 全部完成后容器才会彻底退出。
核心设计
Go-Spring 在实现过程中做出了一系列关键设计决策,这些决策从根本上塑造了容器的整体运行机制。 深入理解这些设计理念,有助于我们更加高效、灵活地使用 Go-Spring。
接口分离
无论一个结构体实现了多少接口,Go-Spring 都会将接口 Bean与原始 Bean视为彼此独立的实体。 如果希望通过接口进行注入,必须使用 Export 方法显式地导出对应接口。
之所以采用这种设计,是因为 Go 语言没有 implements 关键字,若依赖自动推导,容易引发意料之外的行为。 例如,一个结构体可能“恰好”实现了某个接口,但这并非我们的本意,从而导致错误的依赖被注入。
type Service interface {
Do()
}
type ServiceImpl struct{}
func (s *ServiceImpl) Do() {
println("ServiceImpl.Do")
}
func NewServiceImpl() *ServiceImpl {
return &ServiceImpl{}
}
func init() {
// 仅注册 *ServiceImpl,没有导出 Service 接口
// 其他组件无法通过 Service 接口注入,只能依赖具体类型 *ServiceImpl
gs.Provide(NewServiceImpl)
}
func init() {
// 正确方式:显式导出接口
// 这样既可以按接口注入,也保留了对具体类型的支持
gs.Provide(NewServiceImpl).Export(gs.As[Service]())
}按需创建
在大型项目中,依赖关系和组织结构往往非常复杂,很容易引入一些实际上并未使用的 Bean。 为了解决这一问题,Go-Spring 使用了**按需创建(lazy creation)**的策略。
也就是说,在 Go-Spring 中,只有被依赖的 Bean 才会被创建,而未被依赖的 Bean 则不会实例化, 从而避免了不必要的资源开销。
但这也引出了一个关键问题:容器应该从哪些 Bean 开始分析依赖关系,才能确保不会遗漏真正需要的 Bean?
为此,Go-Spring 引入了 root bean(根 Bean)的概念,即依赖树的起点。 容器会从这些 root bean 出发,递归解析并创建其依赖的所有 Bean。
那么,哪些 Bean 会被视为 root bean 呢?
- 首先,实现了
gs.Runner或gs.Server接口的 Bean 会被自动识别为 root bean。 这类 Bean 在应用启动过程中具有特殊作用,由容器自动收集。 - 其次,通过
app.Root()显式标记的 Bean 也会被视为 root bean。 这类 Bean 通常是在 IoC 容器之外使用,但借助容器完成依赖注入。
通过这种机制,Go-Spring 能够在保证依赖完整性的同时,实现精确、可控的 Bean 创建策略。
循环依赖
Go-Spring 可以有限支持循环依赖,具体取决于依赖注入的方式:
如果 A 通过字段注入依赖 B,而 B 同样通过字段注入依赖 A,这种情况是支持的。 容器可以先创建对象实例,再进行属性填充,从而完成依赖闭环。
如果 A 通过字段注入依赖 B,而 B 通过构造函数注入依赖 A,这种情况属于有限支持。 在依赖注入过程中,A 或 B 可能处于“未完全初始化”的状态,因此需要谨慎使用。
如果 A 和 B 都通过构造函数注入互相依赖,则这种情况不被支持。 因为构造函数要求依赖在创建时就必须是完整的,而 A 和 B 无法同时先于对方完成初始化,导致无法解析。
销毁顺序
Go-Spring 严格遵循依赖逆序原则来管理 Bean 的销毁顺序,即:被依赖的 Bean 先初始化,后销毁。
举个例子,如果 A 依赖 B,那么:
- 在初始化阶段:必须先创建 B,才能将其注入到 A 中;
- 在销毁阶段:则先销毁 A,释放其对 B 的依赖,最后再销毁 B。
这种“先建后用、先停后拆”的顺序,确保了在整个销毁过程中,依赖始终是可用的, 从而避免出现“访问已销毁 Bean”的问题。
常见问题
1. 支持运行时动态获取 Bean 吗?为什么不提供 getBean()?
Go-Spring 不支持在运行时动态获取 Bean(即不提供 getBean() 这类 API), 这是一个有意为之的设计选择,主要基于以下考虑:
- 实际需求较少:在大多数服务端应用中,组件依赖关系在启动时就已经确定,很少需要在运行时动态获取 Bean。
- 避免引入复杂性:所有依赖在启动阶段完成解析,运行时代码无需感知容器,调用关系更加直接、清晰。
- 运行时零开销:应用启动完成后,容器不再参与运行逻辑,相关元数据也会被清理,性能与手写代码基本一致。
- 问题尽早暴露:依赖错误会在启动阶段立即暴露,而不是延迟到运行时才发现。
- 依赖显式声明:所有依赖关系都在代码中明确体现,相比运行时动态获取,更易理解和调试。
如果确实存在运行时创建实例的需求,推荐使用工厂模式: 即注册一个工厂单例,由工厂负责创建和管理所需的实例。
2. 为什么接口需要显式导出?不能自动推导吗?
在 Go 语言中,并不存在 implements 关键字,接口的实现是隐式的。 这意味着如果依赖自动推导,一个结构体只要“恰好”实现了某个接口,就可能被错误地匹配,从而引入难以察觉的问题。
此外,在大多数场景下,我们并不一定需要通过接口进行解耦, Go 更推荐在合适的范围内直接使用具体类型,以保持代码简洁。
因此,Go-Spring 要求通过 Export 显式声明接口实现关系,使依赖关系更加明确、可控、可读。
3. 支持循环依赖吗?
Go-Spring 对循环依赖提供有限支持,可以自动处理一部分常见场景(例如基于字段注入的循环依赖)。
但对于某些情况(如构造函数之间的循环依赖),由于对象在创建时必须是完整的,容器无法解析,因此不被支持。
4. 怎么进行单元测试?
详见独立文档 07-testing.md,其中对测试相关内容做了系统说明,主要包括:
- 不依赖 IoC 的纯单元测试写法(推荐,最简单直接)
- 基于 IoC 容器的集成测试方式
- 如何在测试中通过 Mock Bean 覆盖全局注册的默认实现
- Go-Spring 内置断言工具及 Mock 框架的使用方法
5. 支持原型模式(多实例)吗?
Go-Spring 核心只支持单例模式,不直接提供原型(prototype)作用域。 这是一个与 Go 语言特性相匹配的设计选择:
实际需求较少:在 Java 生态中原型模式有一定使用场景,但在 Go 的服务端开发中, 大多数组件天然是无状态或可复用的,单例已经足够覆盖绝大部分需求。
Go 提供更自然的替代方式:如果确实需要创建多个实例,更符合 Go 风格的做法是使用工厂模式, 而不是依赖容器的作用域机制。
典型做法是将工厂本身注册为单例,由工厂负责创建新的实例:
// 工厂本身是单例
type MyServiceFactory struct {
// 工厂可以依赖容器中的其他组件
config Config `autowire:""`
}
// 通过工厂方法创建新实例
func (f *MyServiceFactory) NewMyService(...) *MyService {
return &MyService{
// 工厂负责初始化
config: f.config,
// ...
}
}
func init() {
gs.Provide(new(MyServiceFactory))
}简而言之:如果需要多实例,直接用工厂模式解决即可, IoC 容器的核心职责不需要引入额外的作用域复杂性。
6. 性能怎么样?运行时有反射开销吗?
Go-Spring 在设计上保证了运行时零反射开销:
- 依赖注入仅在启动期执行一次:所有 Bean 的解析与注入都发生在启动阶段, 完成后容器会释放大部分元数据,不再参与运行时逻辑。
- 运行时代码直接调用:应用启动完成后,组件之间的调用与普通 Go 代码无异, 不存在运行时的反射查找或动态解析。
- 反射仅用于初始化阶段:唯一使用反射的地方是在启动期进行依赖解析与字段注入, 这一过程只执行一次。
因此,在应用启动完成后,Go-Spring 的运行性能与手写 Go 代码基本一致,无需额外担心性能损耗。
7. 为什么用 init() 手动注册,而不是自动包扫描?
首先,从 Go 语言本身来看,并不存在真正意义上的“自动包扫描”能力:
- Go 的编译模型决定了:只有被
import的包才会被编译进二进制;未导入的包即使存在代码,也不会被加载。 - 即便要实现“自动注册”,本质上仍然需要通过
import _ xxx的方式触发init()执行。
既然最终都必须依赖 import 才能引入包,那么 Go-Spring 选择直接在 init() 中显式注册 Bean, 而不是引入额外的扫描机制。这样实现也有一些好处:
- 完全显式:哪些 Bean 被注册是明确可见的,不存在“隐式扫描”带来的不确定性。
- 清晰可控:无需注解、配置文件或运行时扫描文件系统,符合 Go “显式优于隐式”的设计哲学。
- 启动更简单:所有注册逻辑在程序启动时通过
init()自动完成,无需额外扫描或解析阶段。
当然,如果希望减少手动编写注册代码,也可以通过代码生成工具在构建期生成 Provide 调用,但这不会改变核心模型。
8. 和 Wire 这类编译期 DI 有什么区别?
| 特性 | Go-Spring | Wire |
|---|---|---|
| 处理时机 | 运行期(启动阶段反射) | 编译期代码生成 |
| 条件注册 | ✅ 原生支持 | ❌ 不支持 |
| 动态配置 | ✅ 原生支持 | ❌ 不支持 |
| 生命周期管理 | ✅ 完整支持 | ❌ 不支持 |
| 启动速度 | 足够快 | 更快 |
从本质上看,Wire 属于编译期依赖注入工具,在构建阶段就完成依赖关系的生成,因此具备更强的静态约束能力, 在启动速度和二进制体积方面也更有优势,适合对极致性能和编译期安全性要求较高的场景。
Go-Spring 则是运行期(启动阶段)依赖注入框架,虽然使用反射完成依赖解析,但仅发生在应用启动阶段, 一旦启动完成,运行时不再依赖反射,因此不会带来持续性能开销。
它的优势主要体现在:
- 支持条件注册,依赖可以根据配置灵活启用或替换
- 支持动态配置,更适合复杂业务环境
- 提供完整生命周期管理能力(初始化、销毁、依赖顺序等)
- 更贴近大型服务端应用的工程化需求
因此可以简单理解为:
- Wire 更偏向“编译期确定一切”
- Go-Spring 更偏向“运行期灵活管理系统结构”
两者并非直接优劣关系,而是针对不同工程约束下的设计取舍。
9. 什么是 root bean?什么时候需要手动标记?
root bean 是依赖树的起点。容器会从 root bean 出发递归解析其依赖, 并确保这些 Bean 一定会被创建,即使它们没有被其他 Bean 显式依赖。
以下情况会自动成为 root bean,无需手动配置:
- 实现了
gs.Runner接口的 Bean - 实现了
gs.Server接口的 Bean(例如 HTTP Server)
这类组件通常代表应用的入口或核心运行单元,容器会在启动阶段自动收集并处理。
如果某个组件需要在容器启动时被主动创建,但它既不是 Runner 也不是 Server, 则可以通过 app.Root() 手动标记为 root bean:
func main() {
c := &MyComponent{}
// ...
gs.Configure(func(app gs.App) {
app.Root(c)
}).Run()
}