思考并回答以下问题:
coredemo/framework/container.go
1 | package framework |
框架主体作为一个服务容器,其他各个服务模块都作为服务提供者,在服务容器中注册自己的服务凭证和服务接口,通过服务凭证来获取具体的服务实例。这样,功能的具体实现交给了各个服务模块,我们只需要规范服务提供者也就是服务容器中的接口协议。
在上节课也已经完成了服务提供方接口的实现。所以今天就接着实现框架的主体逻辑。
服务容器的实现
首先是服务容器,先看它需要具有什么能力。同样的,按照面向接口编程,我们不考虑具体实现,先思考服务容器的接口设计。
将服务容器实现在framework/container.go文件中。
正如之前讨论的,一个服务容器主要的功能是:为服务提供注册绑定、提供获取服务实例,所以服务容器至少有两个方法:注册方法Bind、获取实例方法Make。
- 对于注册的方法,直接将一个服务提供者注册到容器中,参数是之前定义的服务提供者,返回值则是error是否注册成功。
1 | // Bind 绑定一个服务提供者,如果关键字凭证已经存在,会进行替换操作,不返回 error |
- 获取实例的方法是Make,它会根据一个关键字凭证,来获取容器中已经实例化好的服务。所以参数是一个关键字凭证string,返回值是实例化好的服务interface和是否有错误的error信息。
1 | // Make 根据关键字凭证获取一个服务, |
有了这两个基础方法,再考虑在注册绑定及获取服务实例过程中,有什么方面可以扩展。
首先,因为有绑定操作,那么需要有一个确认某个关键字凭证是否已经绑定的能力:IsBind,参数为关键字凭证,返回为bool表示是否已经绑定。
其次,Make方法返回值中带error信息,其实这在易用性上并不友好,因为大部分情况下,我们能确定某个服务容器已经被注册了,并不需要处理这个error。所以可以增加一个MustMake方法,它的参数和Make方法一样,为关键字凭证,返回值为实例化服务,但是不返回error。
最后我们考虑Make的一种拓展场景,是否会有在获取服务实例的时候,按照不同参数初始化的需求?
上节课说服务提供者提供了,初始化服务实例方法的能力Register和获取初始化服务实例参数的能力Params。一旦服务实例被初始化了,它就保存在服务容器中了,下次获取的时候,只需要获取已经实例化好的服务。
但是在某次获取的时候,也会有需求要根据不同参数获取新的实例。比如需要根据不同的配置,获取不同的缓存实例的时候,我们可能需要传递不同的参数。所以可以定义一个MakeNew的方法,根据参数获取不同实例。
整理一下服务容器的五个接口能力,在framework/container.go中代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// Container 是一个服务容器,提供绑定服务和获取服务的功能
type Container interface {
// Bind 绑定一个服务提供者,如果关键字凭证已经存在,会进行替换操作,返回 error
Bind(provider ServiceProvider) error
// IsBind 关键字凭证是否已经绑定服务提供者
IsBind(key string) bool
// Make 根据关键字凭证获取一个服务,
Make(key string) (interface{}, error)
// MustMake 根据关键字凭证获取一个服务,如果这个关键字凭证未绑定服务提供者,那么会 panic。
// 所以在使用这个接口的时候请保证服务容器已经为这个关键字凭证绑定了服务提供者。
MustMake(key string) interface{}
// MakeNew 根据关键字凭证获取一个服务,只是这个服务并不是单例模式的
// 它是根据服务提供者注册的启动函数和传递的 params 参数实例化出来的
// 这个函数在需要为不同参数启动不同实例的时候非常有用
MakeNew(key string, params []interface{}) (interface{}, error)
}
具体实现
现在接口设计好了,下面结合交互思考下如何实现这个服务容器。我们就定义一个HadeContainer数据结构来实现Container接口,因为功能是提供绑定服务和获取服务,需要根据关键字获取一个对象,所以Hash Map是符合需求的。
在HadeContainer内部应该有一个map[string]interface{}
结构instances,其中key为关键字,value为具体的服务实例,这样instances结构可以存储每个关键字凭证对应的服务实例,在Make系列的方法中,就可以根据这个结构获取对应的服务实例。
同理服务提供方也需要设计一个map[string]ServiceProvider来存储它们,这样在Bind操作的时候,只需要将服务提供方绑定到某个关键字凭证上即可。
另外还要关注一下数据结构的并发性。因为当Bind的时候会对实例或者服务提供方有一定的变动,需要使用一个机制来保证HadeContianer的并发性,是用读写锁还是互斥锁呢?
这里我们就要关注功能了,这个HadeContainer是一个读多于写的数据结构,即Bind是一次性的,但是Make是频繁的。所以使用读写锁的性能会优于互斥锁。
好整理一下,在framework/container.go中HadeContainer的字段定义如下:1
2
3
4
5
6
7
8
9
10// HadeContainer 是服务容器的具体实现
type HadeContainer struct {
Container // 强制要求 HadeContainer 实现 Container 接口
// providers 存储注册的服务提供者,key 为字符串凭证
providers map[string]ServiceProvider
// instance 存储具体的实例,key 为字符串凭证
instances map[string]interface{}
// lock 用于锁住对容器的变更操作
lock sync.RWMutex
}
然后对应HadeContainer,来实现Container定义的几个方法就比较容易了,本质上就是对providers和instances两个map结构的修改和读取。这里最核心的Bind方法,我会结合代码讲得比较细致,你可以认真体会。
Bind方法
首先因为Bind方法是一个写操作,会修改providers和instances,所以在函数一开头,先给加上一个写锁,然后我们修改providers这个字段,它的key为关键字,value为注册的ServiceProvider。
接着这里需要先判断是否实例化,因为定义的ServiceProvider中的IsDefer方法,控制了实例化时机。
如果IsDefer方法标记这个服务实例要延迟实例化,即等到第一次make的时候再实例化,那么在Bind操作的时候,就什么都不需要做;而如果IsDefer方法为false,即注册时就要实例化,那么我们就要在Bind函数中增加实例化的方法。
所以接下来实现实例化,方法和参数就是ServiceProvider中的Register和Params方法,分别取出实例化方法和参数进行调用,就获取到了具体的服务实例。
最后还有一点要注意下,之前为ServiceProvider定义过一个Boot方法,是为了服务实例化前做一些准备工作的。所以在实例化之前,要先调用这个Boot方法,同样在framework/container.go中进行修改。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24// Bind 将服务容器和关键字做了绑定
func (hade *HadeContainer) Bind(provider ServiceProvider) error {
hade.lock.Lock()
defer hade.lock.Unlock()
key := provider.Name()
hade.providers[key] = provider
// if provider is not defer
if provider.IsDefer() == false {
if err := provider.Boot(hade); err != nil {
return err
}
// 实例化方法
params := provider.Params(hade)
method := provider.Register(hade)
instance, err := method(params...)
if err != nil {
return errors.New(err.Error())
}
hade.instances[key] = instance
}
return nil
}
Make方法
为服务提供注册绑定的Bind方法我们就完成了,再来看服务容器的另一个功能提供获取服务实例,也就是Make方法。
先判断某个关键字是否已经注册了服务提供者,如果没有注册则后续不能成功返回错误,如果已经注册了就进行实例化操作。
同时注意下,在上面解释过,也会有扩展需求,按照不同参数再次初始化服务的需求。也就是MakeNew方法,它和Make方法在内部调用最大的不同是传递了一个强制初始化的标记forceNew,和初始化需要的参数params。
所以,在两个函数共同的内部实现make方法中,我们要先判断是否需要强制初始化实例,如果需要强制初始化,初始化后直接返回。而不需要强制初始化,那么就需要判断之前是否已经实例化了,如果已经实例化了,则返回。
方法的实现同样在framework/container.go中:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38// Make 方式调用内部的 make 实现
func (hade *HadeContainer) Make(key string) (interface{}, error) {
return hade.make(key, nil, false)
}
// MakeNew 方式使用内部的 make 初始化
func (hade *HadeContainer) MakeNew(key string, params []interface{}) (interface{}, error) {
return hade.make(key, params, true)
}
// 真正的实例化一个服务
func (hade *HadeContainer) make(key string, params []interface{}, forceNew bool) (interface{}, error) {
hade.lock.RLock()
defer hade.lock.RUnlock()
// 查询是否已经注册了这个服务提供者,如果没有注册,则返回错误
sp := hade.findServiceProvider(key)
if sp == nil {
return nil, errors.New("contract " + key + " have not register")
}
if forceNew {
return hade.newInstance(sp, params)
}
// 不需要强制重新实例化,如果容器中已经实例化了,那么就直接使用容器中的实例
if ins, ok := hade.instances[key]; ok {
return ins, nil
}
// 容器中还未实例化,则进行一次实例化
inst, err := hade.newInstance(sp, nil)
if err != nil {
return nil, err
}
hade.instances[key] = inst
return inst, nil
}
容器和框架的结合
完成了服务容器的接口和对应具体实现,下面就要思考如何将服务容器融合进入框架中。
回顾下hade框架中最核心的两个数据结构Engine和Context。
- Engine就是在第一节课中实现的Core数据结构,这个数据结构是整个框架的入口,也承担了整个框架最核心的路由、中间件等部分。
- Context数据结构对应第二课中实现的Context数据结构,它为每个请求创建一个Context,其中封装了各种对请求操作的方法。
对应来看我们的服务容器,它提供了两类方法,绑定操作和获取操作。绑定操作是全局的操作,而获取操作是在单个请求中使用的。所以在全局,我们为服务容器绑定了服务提供方,就能在单个请求中获取这个服务。
那么对应到框架中,可以将服务容器存放在Engine中,并且在Engine初始化Context的时候,将服务容器传递进入Context。思路很清晰,接下来就按部就班写。
首先,在框架文件framework/gin/gin.go中修改Engine的数据结构,增加container容器,并且在初始化Engine的时候,也同时初始化container。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17type Engine struct {
// 容器
container framework.Container
...
}
func New() *Engine {
debugPrintWARNINGNew()
engine := &Engine{
...
// 这里注入了 container
container: framework.NewHadeContainer(),
...
}
...
return engine
}
接着,在Engine创建context的时候,我们将engine中的container容器注入到每个context中,并且修改Context的数据结构,增加container容器。同样修改framework/gin/gin.go中的allocateContext方法。1
2
3
4
5
6// engine 创建 context
func (engine *Engine) allocateContext() *Context {
v := make(Params, 0, engine.maxParams)
// 在分配新的 Context 的时候,注入了 container
return &Context{engine: engine, params: &v, container: engine.container}
}
和修改framework/gin/context.go中的Context定义。1
2
3
4
5
6// Context 在每个请求中都有
type Context struct {
// Context 中保存容器
container framework.Container
...
}
这样就完成了服务容器的创建和传递,接下来完成服务容器方法的封装。
根据上面描述的,Engine中负责绑定,Context中负责获取,所以我们将container的五个能力拆分到Engine和Context数据结构中。Engine封装Bind和IsBind方法,Context封装Make、MakeNew、MustMake方法。
将这些为Engine和Context增加的新的方法单独存放在一个新的文件framework/gin/hade_context.go中。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25// engine 实现 container 的绑定封装
func (engine *Engine) Bind(provider framework.ServiceProvider) error {
return engine.container.Bind(provider)
}
// IsBind 关键字凭证是否已经绑定服务提供者
func (engine *Engine) IsBind(key string) bool {
return engine.container.IsBind(key)
}
// context 实现 container 的几个封装
// 实现 make 的封装
func (ctx *Context) Make(key string) (interface{}, error) {
return ctx.container.Make(key)
}
// 实现 mustMake 的封装
func (ctx *Context) MustMake(key string) interface{} {
return ctx.container.MustMake(key)
}
// 实现 makenew 的封装
func (ctx *Context) MakeNew(key string, params []interface{}) (interface{}, error) {
return ctx.container.MakeNew(key, params)
到这里,我们就将服务容器和框架结合了,还需要完成创建服务提供方并创建服务的逻辑。
如何创建一个服务提供方
下面我们来创建一个服务DemoService,为这个服务创建一个服务提供方DemoServiceProvider,并注入到服务容器中。在业务目录中创建一个目录provider/demo存放这个服务。
先搞清楚需要为这个服务设计几个文件。
- 要有一个服务接口文件contract.go,存放服务的接口文件和服务凭证。
- 需要设计一个provider.go,这个文件存放服务提供方ServiceProvider的实现。
- 最后在service.go文件中实现具体的服务实例。
所以先来看第一个服务接口说明文件contract.go,在文件中要做两个事情。一是定义一个服务的关键字凭证,这个凭证是用来注册服务到容器中使用的,这里使用“hade:demo”来作为服务容器的关键字。
另外在这个文件中,我们要设计这个服务的接口,包括接口方法和接口方法使用的对象。比如这里就设计了demo.Service接口,它有一个GetFoo方法,返回了Foo的数据结构。1
2
3
4
5
6
7
8
9
10
11
12
13
14package demo
// Demo 服务的 key
const Key = "hade:demo"
// Demo 服务的接口
type Service interface {
GetFoo() Foo
}
// Demo 服务接口定义的一个数据结构
type Foo struct {
Name string
}
接下来是服务提供方DemoServiceProvider,在上一节课中,我们描述了服务提供方ServiceProvider需要实现的五个能力方法,一一对照完成定义。
- Name方法直接将服务对应的字符串凭证返回,在这个例子中就是“hade.demo”。
- Register方法,是注册初始化服务实例的方法,这里先暂定为NewDemoService。
- Params方法表示实例化的参数。这里只实例化一个参数:container,表示我们在NewDemoService这个函数中,只有一个参数container。
- IsDefer方法表示是否延迟实例化,这里设置为true,将这个服务的实例化延迟到第一次make的时候。
- Boot方法,这里就简单设计为什么逻辑都不执行,只打印一行日志信息。
在provider/demo/provider.go中定义逻辑:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29// 服务提供方
type DemoServiceProvider struct {
}
// Name 方法直接将服务对应的字符串凭证返回,在这个例子中就是“hade.demo"
func (sp *DemoServiceProvider) Name() string {
return Key
}
// Register 方法是注册初始化服务实例的方法,这里先暂定为 NewDemoService
func (sp *DemoServiceProvider) Register(c framework.Container) framework.NewInstance {
return NewDemoService
}
// IsDefer 方法表示是否延迟实例化,我们这里设置为 true,将这个服务的实例化延迟到第一次 make 的时候
func (sp *DemoServiceProvider) IsDefer() bool {
return true
}
// Params 方法表示实例化的参数。我们这里只实例化一个参数:container,表示我们在 NewDemoService 这个函数中,只有一个参数,container
func (sp *DemoServiceProvider) Params(c framework.Container) []interface{} {
return []interface{}{c}
}
// Boot 方法我们这里我们什么逻辑都不执行, 只打印一行日志信息
func (sp *DemoServiceProvider) Boot(c framework.Container) error {
fmt.Println("demo service boot")
return nil
}
在最后的具体Demo服务实现文件service.go中,我们需要实现demo的接口。创建一个DemoService的数据结构,来实现demo的接口GetFoo,和正常写一个服务实现某个接口的逻辑是一样的。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 具体的接口实例
type DemoService struct {
// 实现接口
Service
// 参数
c framework.Container
}
// 实现接口
func (s *DemoService) GetFoo() Foo {
return Foo{
Name: "i am foo",
}
}
这里不知道你有没有发现,在DemoService的数据结构中,直接嵌套显式地写了Service接口,表示这个DemoService必须实现Service接口。这是我个人的小习惯,当然这里也可以不用这样显式强调接口实现的,因为在Golang中,一个数据结构只需要实现了一个接口的方法,它就隐式地实现了这个接口。
但是我还是习惯这样显式强调的写法,也推荐你可以试一试,有两个好处。
第一对IDE友好,有的IDE比如VSCode,在“根据接口查找具体实现的数据结构”的时候,如果没有这么一个显式标记,是寻找不出来的。第二个更重要的原因是对编译友好,一旦接口Service变化了,那么这个实现接口的实例,必须要有对应变化,否则在编译期就会出现错误了。
现在有了具体的接口实现结构DemoService,我们只需要最后实现一个初始化这个服务实例的方法NewDemoService。它的参数是一个数组接口,返回值是服务实例。还是把这个方法存放在provider/demo/service.go中:1
2
3
4
5
6
7
8
9// 初始化实例的方法
func NewDemoService(params ...interface{}) (interface{}, error) {
// 这里需要将参数展开
c := params[0].(framework.Container)
fmt.Println("new demo service")
// 返回实例
return &DemoService{c: c}, nil
}
到这里,就创建了一个示例的服务提供方DemoService。下面再看看如何使用这个服务提供方。
如何通过服务提供方创建服务
这里实现非常简单,我们需要做两个操作,绑定服务提供方、获取服务。
首先是在业务文件夹的main.go中绑定操作,在main函数中,完成engine的创建之后,用在engine中封装的Bind方法做一次绑定操作。1
2
3
4
5
6
7func main() {
// 创建 engine 结构
core := gin.New()
// 绑定具体的服务
core.Bind(&demo.DemoServiceProvider{})
...
}
然后就是服务的获取了。在具体的业务逻辑控制器中,我们选择路由/subject/list/all对应的控制器SubjectListController,使用为context封装的MustMake方法来获取demo服务实例。
MustMake的参数为demo的服务凭证demo.Key,返回的是一个interface结构,这个interface结构实际上是实现了demo.Service接口的一个服务实例。
而在接口的具体输出中,输出的是这个接口定义的GetFoo()方法的输出,也就是最终会从服务容器中获取到DemoService的GetFoo()方法的返回值Foo结构,带有字段Name:“iamfoo”输出在页面上。1
2
3
4
5
6
7
8
9
10
11// 对应路由 /subject/list/all
func SubjectListController(c *gin.Context) {
// 获取 demo 服务实例
demoService := c.MustMake(demo.Key).(demo.Service)
// 调用服务实例的方法
foo := demoService.GetFoo()
// 输出结果
c.ISetOkStatus().IJson(foo)
}
最后验证一下,在浏览器中,我们访问这个路由/subject/list/all,获取到了Foo数据结构Json化出来的结果,如下图,验证完毕。
现在服务容器、服务提供者的整个框架主体就搭建完成了。
今天所有的代码都已经上传到GitHub的geekbang/11分支了。目录截图如下:
小结
我们在主体框架中实现了服务容器、服务提供者的逻辑,现在,hade框架就包含一个服务容器,所有的服务都会在服务容器中注册。当业务需要获取某个服务实例的时候,也就是从服务容器中获取服务。
在这节课中,你是不是能感知到服务容器的方便之处了。只需要往服务容器中注册服务提供者,之后不管任何时候,想要获取某个服务,都能很方便地从服务容器中,获取到符合服务接口的实例,而不需要考虑到服务的具体实现。这种设计的拓展性非常好,之后在实际业务中我们只要保证服务协议不变,而不用担心具体的某个服务实现进行了变化。
后续开发的所有服务模块,比如日志、配置等我们都会以服务的形式进行开发,先定义好服务的接口,后定义服务的服务提供者,最后再定义服务的具体实例化方法。
思考题
在实现服务容器时,不知道你会不会有一个疑问:我们将服务容器的Make系列的方法在Context中实现了,为什么不把Bind系列方法也在Context中实现呢?这个问题你会怎么思考呢?Context允许Bind方法有什么好处和什么不好的地方呢?