花3分钟写的简易IoC,放在Golang的项目中太好用了~
为什么说IoC只是一种代码的指导思想
写在前面
和几个小伙伴在字节跳动第五届青训营后端组中,编写的Dousheng项目大作业获奖了。如果是Javer,肯定使用过Spring,那么应该知道IoC,但并不是Spring才能使用IoC。它是一种如何编写代码的指导思想,比如我们的Go Project中,一样使用到了这个思想。来看看我们是如何使用的吧。
首先附上项目地址:
青训营之旅结束了,我给朋友(简称小cher算了🕵️♂️🕵️♂️)
看了我在我Go Project
中实现的简易版IOC,实现思路如图所示:
什么?小cher说她看不懂!!!好吧,我的锅。那我来解释解释,什么是IoC?
注:本文是利用了Ioc的一些思想,实现了简易版的IoC容器,和IoC的原思想并不完全一致。可以小马过河,因人而异~
一、什么是IOC
🕵️♂️🕵️♂️小cher说,你用一句话解释I一下IoC
的核心思想:
一句话解释:将对象利用控制反转的方式,在容器中创建出Bean(项目的依赖对象)
,并且可以自动为每一个Bean
注入所需的依赖。
再用一句话解释简易版IoC:将依赖对象注入容器中后,用控制反转的方式,从IoC容器中获取所需要的依赖。
🎅🎅我还补充了一句:
当然,一般从IoC中取出的依赖,也是为了注入给容器中的其它依赖。
🕵️♂️🕵️♂️这一下小cher就懵了啊:
什么是控制反转?什么又是依赖?注入之后怎么取出来勒?为什么会有IoC勒?
没关系,咱们慢慢来看,就聊聊天嘛~
(1)什么是依赖注入?
🎅🎅小cher啊,我先跟你说官方一点的啊:
1、官方一点的
DI(Dependency Injection)
称为依赖注入。意思是:如果A实例依赖B实例,如下代码所示:
type A struct {
// A对象依赖对象 B
b B
}
type B struct {
name string
}
在程序启动时的时候,会去初始化IoC容器,去初始化对象A的时候,扫描到它需要依赖对象B。IoC会从自身取出B,给A对象中的b字段
赋值。
🕵️♂️🕵️♂️我说完这句话后:小cher说:
这个过程,如果给A赋值的时候,B对象还未初始化呢?那之后如果调用A.b的方法,不就相当于A.nil.xxx
了嘛?
是的,所以我们要求,在进行依赖注入的时候,必须要能够在容器中能找到被依赖的对象。
🕵️♂️🕵️♂️话音刚落,小cher加上了自己的思考,已经理解大概的意思了,他说:那还有没有简单一点的描述?
2、简易版
简单说明了上面的那种解释,再来强化一下理解,因为我们要实现的简易版,依赖注入指的是:
如果将程序刚开始启动时分为两个阶段
- 阶段一:通过一定的手段,将依赖放入IoC对应的容器中
- 阶段二:去初始化容器中的对象,也就是给容器中每一个对象的属性赋值。
也就是图中的这个过程:
🕵️♂️🕵️♂️图还没看完,小cher说,欧,原来这两个步骤就是依赖注入的过程啊!!!这个懂了,但是我还有个疑惑:你说通过一定的手段。具体指的是什么呢?
(2)怎么注入容器?
是啊,怎么通过一定的手段将依赖放入容器呢?这里有几个可借鉴的手段:可以是配置文件、可以是注解、注释...
🎅🎅撇开上面这句话,小cher啊,如果有一个盒子,让你把一个球放入一个盒子里,你会怎么放入呢?
🕵️♂️🕵️♂️我啊,我会大致会有两个思路吧:
- 自己手动放进去咯
- 跟我女朋友说,当我这个盒子打开的时候,她就把球自动放进去~🐶🐶
🎅🎅 ???满脸羡慕,有女朋友真幸福是吧~ 咳咳,先继续来看这个问题:
呀,还挺会思考的勒。主线思路也就是自动放入和手动放入咯,但是我们作为”高级“程序员,会手动操作吗?
所以,我们注入容器的过程,就选择自动注册,那怎么注册呢?既然我这里是Go Project
,Go语言
的程序员围过来,其他语言围观,我们来看一段 Go官方的Mysql驱动包 的代码(主要看标注的):
🎅🎅小cher,相信你猜出我想说什么了吧!
🕵️♂️🕵️♂️那我浅猜一下?你是不是想说:
在程序刚开始启动时,如果你的启动入口导入了对应的包,那么就会去加载那个包的一些东西。我在网上看到了一副图片,分享给你看看:
而使用匿名导入,是因为在导入包的地方不需要使用它,只是想去初始化那个包。你看看我说的对吗??
🎅🎅哇偶,可以啊,小cher你是懂我的!!!来,碰个杯🍻我再接着你的思路往下说
我们知道了这个原理,那么我们就可以将要放入容器的操作写到init()
函数里,比如下面两段伪代码:
package api
// 这个包有依赖需要放入IoC容器
// ...
func init() {
// 放入IoC容器的逻辑
ioc.DI(需要放入的依赖)
}
那么在启动程序的时候:
package main
import (
_ "test/api" // 去初始化 api 包
)
func main() {
// 启动程序
// ....
}
🕵️♂️🕵️♂️小cher看完了这两段代码,豁然开朗,原来是这样啊!!!那我想问问,说控制反转,控制怎么就反转了呢?
(2)控制怎么就反转了?
🎅🎅好的,小cher,看这个问题之前,你再回想一下,你在项目中一般是怎么使用依赖对象的?最好可以写点简单的代码给我看看
🕵️♂️🕵️♂️呐,你看
// 对象A
type A struct {
id int
b *B // 这里依赖 B 对象
}
// 对象B的构造函数
func NewB(name string) *B {
return &B{name: name}
}
// 对象B
type B struct {
name string
}
func main() {
a := A{
id: 20,
b: NewB("ciusyan"), // 自己控制B对象的初始化
}
fmt.Println(a.b.name)
}
是啊,你看看,你写的这个,你的A对象依赖B对象,你要是给A对象初始化。你需要自己去写初始化代码。这样我觉得麻烦的地方就是:
- 如果你这个类似的代码在100个地方用了,那么你就会写一百遍类似的代码。如果你的参数突然变化了,那么你又要到那用到的一百个地方修改代码
- 需要自己理清楚所依赖的对象
🕵️♂️🕵️♂️也是偶,那我可以封装一下啊!!!哎,不对,你不就是在利用IoC
的思想封装吗
是的,我们使用IoC的方式封装后(你先别管具体怎么封装的),初始化的操作,都被放入容器中了。需要使用对象的时候,直接从容器中取出来即可,比如:
func main() {
// 从IOC中获取A对象
a := ioc.Get("A")
// 它是如何初始化A和B的,我们根本不需要关心
fmt.Println(a.b.name)
}
如上代码所示,我们从IoC中取出了想要使用的对象A,它的内部是如何初始化对象B的,我们根本不用关心,也不必挂念,它到底是用阿猫、还是阿狗来初始化的。
这下对B的控制权,不就反转了吗?从你自己控制,反转成了IoC容器帮你控制。
再给你找一副图看看:
🕵️♂️🕵️♂️好吧,确实是,用IoC
封装后,使用起来好方便啊,都不需要自己管理依赖的对象了!!!再看你开始给我看的图,好像清晰了很多
相信你看到这里,你大概也知道为什么会出现IoC了,那我们再来总结一下~
二、为什么会有IOC
一句话解释:方便管理项目的依赖的对象。
刚刚所述的,如果很多重复性很大的代码,这个点咱们不重复了叙述了。下面来看看,如果一个对象的依赖很多,那么你可能去理清这个对象的依赖,会很麻烦。
(1)没有IoC时
1、依赖关系混乱
如果你的对象很多,对象间的依赖关系可能很难管理,你可能很难理清楚它们之间的关系。比如下图:
将上图的依赖关系转换为一段伪代码:
// 对象A
type A struct {
id int
b *B // 这里依赖 B 对象
c *C // 这里依赖 C 对象
d *D // 这里依赖 D 对象
e *E // 这里依赖 E 对象
f *F // 这里依赖 F 对象
g *G // 这里依赖 G 对象
// ....
}
func main() {
// 创建G对象
g := NewG()
// 创建I对象,它依赖G
i := NewI(g)
// 创建D对象。它依赖I
d := NewD(i)
// 创建H对象,它依赖D
h := NewH(d)
// 创建对象A
a := A{
id: 20,
b: NewB(h), // 自己控制B对象的初始化,B依赖H
c: NewC(), // 自己控制C对象的初始化
d: d, // 自己控制D对象的初始化
e: NewE(), // 自己控制E对象的初始化
f: NewF(), // 自己控制F对象的初始化
g: g, // 自己控制G对象的初始化
// ....
}
// 使用A对象
fmt.Println(a.id)
}
🕵️♂️🕵️♂️小cher看完了这段代码,笑道:之前对象的依赖没那么多,好像确实没多少感觉,看你这个依赖多了后,好像是有点麻烦勒
哈哈,没骗你吧,我这还是随便画的图片,看起来还没那么混乱,你可能还是能理清楚,但是我们再来看一种情况。
2、循环依赖问题
如果是在Go语言中,你每依赖一个对象,就需要导入对应的包,依赖关系混乱可能还能够理清楚,但是如果依赖形成一个环状,你导入包的时候,可能会导致包循环导入 (import circle)的问题。
比如下面这段伪代码:
这是A包的对象,它依赖B包的对象
package A
import "circle/B"
type AImpl struct {
b *B.BImpl
}
这是B包的对象,它依赖C包的对象
package B
import "circle/C"
type BImpl struct {
c *C.CImpl
}
这是C包的对象,它依赖A包的对象
package C
import "circle/A"
type CImpl struct {
a *A.AImpl
}
可以看到这三段代码的 import ,大致是这样的:A -> B,B -> C,C -> A,这样的关系,你使用其中任意一个对象,都会报错。比如这样使用:
package main
import "circle/A"
func main() {
// 使用其中一个对象,都会报错
_ = A.AImpl{}
}
如果你运行上面的代码,会导致形如下面这样的错误:
package circle
imports circle/A
imports circle/B
imports circle/C
imports circle/A: import cycle not allowed
再画一张图给你看看,上面的依赖关系是这样的,循环依赖了。
(2)有IoC之后
看完了上面的两个问题,我想,你多少可能都遇到过上面的问题吧!那来看看有IoC之后呢?
// 定义这些对象的时候,将这些对象全部注入IoC容器中
ioc.DI(A, B, C, D, E, F, G, H)
// 使用对象A
a := ioc.Get("A")
如果站在对象A维护者的角度来看,我们将A对象放入容器中后,正确的给其注入所需的依赖。在外界使用者的眼里,就只需要 ioc.Get()
获取对象来使用即可。外界甚至都可以不用知道这里面是如何初始化的。这不就给使用者一个很大的便利了吗?因为不用花时间去整理依赖关系了~
而且现在依赖都是由IoC管理的,创建者只需要将对象创建好,放入IoC中即可,这样的话,IoC在进行依赖注入的时候,都是从容器里面取依赖,根本不会有循环导入的问题了~
当然啊,并不是说没有IoC就不行啊,但若你还没有想到好的方案,不妨可以试试用IoC容器化的思想去封装一些东西?
🎅🎅小cher,聊到现在,相信你应该了解了IoC的一些核心概念,那咱们开始制作装依赖的容器,看看3分钟能不能写完吧!
三、开始实践了
(0)实现铺垫
一般会用很多种容器,来装不一样的依赖。
🎅🎅小cher,问你个问题,你家里的大衣柜,你不可能把所有衣物,一啪啦的全扔进去吧。
🕵️♂️🕵️♂️肯定不会啊,要不然到时候取的时候太麻烦了,可以分开管理一下的
是我的话,我可能会把它分成很多个装衣服的容器:有装衬衫的、装领带的、装西装、装裤子...
🎅🎅哈哈哈,是的,既然你也这样认为。那我们这里也可以多做几个容器,用于装不一样的对象,分开管理。就先来实现一个用来装Gin的HTTP服务对象的容器吧,其余的也是类似思路!
这里以在Main方法中注册HTTP的Handler为例,比如现在在main中是这样使用的:
func main() {
// 1、加载配置文件
if err := conf.LoadConfigFromToml(configFile); err != nil {
panic(err)
}
// 2、通过Gin启动HTTP服务
g := gin.Default()
// 3、注册对应服务的路由
// 获取UserHandler对象,并且注册路由
userService := impl.NewUserServiceImpl()
userApi := http.NewUserHttpHandler(userService) // UserHandler 依赖 UserService
userApi.Registry(g)
// 获取VideoHandler对象,并且注册路由
videoService := impl.NewVideoServiceImpl()
videoApi := http.NewVideoHttpHandler(videoService) // VideoHandler 依赖 VideoService
videoApi.Registry(g)
// 依此类推,注册其他服务的路由 ....
// 4、启动监听TCP连接
g.Run(conf.C().App.HttpAddr())
}
这里一定要看明白哟,因为我们就是要对这一段代码做改进,这里的逻辑其实也不难:
- 读取项目所需配置
- 将路由注册到Gin中去监听
- 启动HTTP服务,开始监听连接
重点看第二步,我们这里假设就有两个模块,User 和 Video。我们只注册了两次。如果有10个模块的Handler,那么这样的代码就需要写10遍,它们依赖的Service也需要获取十遍。
那我们如何封装呢?跟着我的思路
(1)初阶简易版本
既然这里Handler对象需要我们手动注入依赖的Service对象,那我们能不能先把依赖的控制权,先反转呢?
1、建立IoC包
先建立一个IoC的包,这里面装着依赖的两个Service对象
package ioc
import (
"github.com/Go-To-Byte/DouSheng/apps/user"
"github.com/Go-To-Byte/DouSheng/apps/video"
)
// IOC容器:管理所有服务的实例
var (
UserService user.Service
VideoService video.Service
// .... 其他依赖
)
注意这里都要用对应Service的接口欧, 我这里就不赘述为什么要面向接口编程了~
2、修改Main的代码
那么我们在main中,就可以这样改进那一段代码了,为了方便,我只保留我们要封装的代码:
func main() {
// 3、注册对应服务的路由
// 将所需的依赖对象的实现,手动注入到IoC包中去。
ioc.UserService := impl.NewUserServiceImpl()
ioc.VideoService := impl.NewVideoServiceImpl()
userApi := http.NewUserHttpHandler()
userApi.Registry(g)
// 获取VideoHandler对象,并且注册路由
videoService := impl.NewVideoServiceImpl()
videoApi := http.NewVideoHttpHandler(videoService) // VideoHandler 依赖 VideoService
videoApi.Registry(g)
// 依此类推,注册其他服务的路由 ....
}
在这里,我们先将IoC中接口所需要的实现,给手动注入进去(给依赖对象赋值)
然后我们直接新建一个对象即可,不需要手动传入这个依赖的Service对象了
3、修改构造函数
那么这两个Handler的初始化的构造方法,就得修改一下了:
// User Handler 的构造函数
func NewUserHttpHandler() *UserHandler {
if ioc.UserService == nil {
panic("IOC中依赖为空:UserService")
}
return &Handler{
service: ioc.UserService, // 这里依赖IoC中的UserService对象
}
}
// Video Handler 的构造函数
func NewVideoHttpHandler() *VideoHandler {
if ioc.VideoService == nil {
panic("IOC中依赖为空:VideoService")
}
return &Handler{
service: ioc.VideoService, // 这里依赖IoC中的VideoService对象
}
}
在构造函数中初始化的时候,直接从IoC中去获取依赖的Service对象。
4、初阶版总结
小cher看完这个初阶版本,心里有一万只🐏🐏。在初阶版,你可能看到我感觉什么都没做,但是你仔细思考一下,我们的初阶版本
是不是在使用依赖前,将依赖注入进去了?需要使用的时候,是不是直接从IoC中取出来的?别着急,咱继续往下看看。
感觉也没问题,但是其实还有很多问题,比如:
- 需要手动注入依赖
- 控制并没有完全反转
- 注入的依赖很随意
- 代码还是不够精简
那我们接着来改进改进,让它看起来更规范些吧~
(2)完整简易版本
1、规范IoC容器的对象的接口
先定义一组接口,想要将对象放入装Gin的HTTP对象的容器中,必须要实现这个接口。
// GinDependency Gin HTTP的服务实例想要注入此容器,必须实现该接口
type GinDependency interface {
// Init 如何初始化注入此 IoC 的实例
Init() error
// Name 注入服务模块的名称
Name() string
// Registry 该模块所需要注册的路由
Registry(r gin.IRoutes)
}
如果你将依赖放入了IoC中,你还得告诉它,要如何初始化,所以需要实现Init()
方法。每放入一个依赖,用什么来标识它,所以需要实现Name()
方法。又因为是Gin的容器,用于管理Http的Handler的,需要注册路由和它对应的路由函数,所以需要实现Registry(r gin.IRoutes)
方法,告诉gin,IoC容器中的实例,要注册哪些路由。
为什么要定义这样的规范呢?当然是为了不用每添加一个模块,就来IoC这里面预定义一个变量呐。所以抽象了一套接口。大家就可以统一使用了。
var (
// Gin依赖的 IoC 容器
ginContainer = map[string]GinDependency{}
)
我们统一将这些对象装入一个Map中,你可能会问,为什么要用Map呢?因为方便取出来,我们每放入一个依赖时,给它标注一个唯一的名字,之后需要取出来的时候,通过这个名字就可以取出来了,这种一一对应的关系,用Map映射很方便。
那你可能又会问,Map 是并发不安全的,防止并发问题,不需要加锁?或者使用Sync.Map吗?哈哈,你说的没问题,考虑得还挺周到。但是我们这些依赖对象,是需要在初始化的时候,就装载的。就像程序的配置文件一样,如果一开始启动的时候出现了问题,你还会启动程序吗?所以我们这里在启动后,最多只会读取IoC容器中的内容,并不会写入,所以不用担心并发问题。
2、提供几个方法
方便外界注入和使用依赖,提供几个函数,函数的逻辑也很简单,并且有对应的注释,可以简单看一下。我就不多赘述了。
// GinDI :将依赖注入此容器,Gin DI(Gin Dependency Inject)
func GinDI(dependency GinDependency) {
dependencyName := dependency.Name()
// 1、检查服务是否已经被注册
if _, ok := ginContainer[dependencyName]; ok {
panic(fmt.Sprintf("[ %s ]服务的依赖已在容器中,请勿重复注入", dependencyName))
}
// 2、未注入,放入容器
ginContainer[dependencyName] = dependency
}
// GetGinDependency 根据模块名称 获取内部服务模块的依赖,外部使用时需自己断言,如:
//
// userGin = ioc.GetGinDependency("user").(user.UserGin)
func GetGinDependency(name string) GinDependency {
if v, ok := ginContainer[name]; ok {
return v
} else {
panic(fmt.Sprintf("容器中没有此依赖[ %s ]", name))
}
}
// RegistryGin 注册所有的Gin Http 路由Handler
func RegistryGin(r gin.IRouter) {
// 初始化所有对象的路由
for _, v := range ginApps {
// 调用它的注册函数
v.Registry(r)
}
}
// ExistingGinDependencies 返回Gin HTTP服务依赖的容器中已存在的依赖名称
func ExistingGinDependencies() (apps []string) {
for k, _ := range ginContainer {
apps = append(apps, k)
}
return
}
但是你还没看到这里面,并没有去调用对应的Init()
方法,并没有看到我去初始化这个对象,所以还需要提供一个初始化的函数。又因为我们开头说了,有多个容器,装着不一样的依赖对象,所以我们可以提供一个统一初始化IoC的函数。
3、统一初始化函数
如下代码所示:我们这里提供了三个容器,有内部服务对象的容器、GRPC对象的容器、Gin的Handler对象的容器。我们这里面是用Gin的来举例的,其实每一个容器的实现大同小异。可以看看具体代码~
// InitAllDependencies 用于初始IoC容器中的所有依赖
func InitAllDependencies() error {
// 初始化内部服务模块依赖
for _, v := range internalContainer {
if err := v.Init(); err != nil {
return err
}
}
// 初始化GRPC服务依赖
for _, v := range grpcContainer {
if err := v.Init(); err != nil {
return err
}
}
// 初始化Gin HTTP服务依赖
for _, v := range ginContainer {
if err := v.Init(); err != nil {
return err
}
}
return nil
}
有了这个函数之后,我们来看看如何使用我们的IoC容器吧
4、使用
- 先看看是如何将依赖对象放入容器的:
// 用于注入IOC中
var handler = &Handler{}
// Handler 通过一个实体类,把内部接口用HTTP暴露出去【控制层Controller】
type Handler struct {
service user.UserService
log *zap.SugaredLogger
}
// Registry 用于注册Handler所需要暴露的路由
func (h *Handler) Registry(r route.IRoutes) {
r.POST("/register/", h.Register)
r.POST("/login/", h.Login)
r.GET("/", h.GetUserInfo)
}
// Init 初始化Handler对象
func (h *Handler) Init() error {
// 从内部服务的IOC中获取UserServiceImpl实例
h.service = ioc.GetInternalDependency(user.AppName).(user.Service)
// 如何初始化日志对象
h.log = zap.S().Named("USER HTTP")
return nil
}
func (h *Handler) Name() string {
return user.AppName // user
}
func init() {
// 将此Gin服务注入IOC中
ioc.GinDI(handler)
}
User模块的Handler对象,实现了放入Gin容器的接口,将其放入Gin的IoC容器中GinDI()方法
。并且告诉了,如何初始化此对象Init()方法
、注册什么路由Registry()方法
、用什么名字标识自己Name()方法
。
但是我们这里仅仅是注册好了。并没有人来初始化这个包哎,什么时候注入呢?
- 什么时候注入
刚刚小cher说的,我们可以通过导包的方式,来执行每个文件中的init()
方法,也就是形如这样的
import (
// 将对象放入内部服务的IoC中
_ "github.com/Go-To-Byte/DouSheng/apps/user/impl" // UserService
_ "github.com/Go-To-Byte/DouSheng/apps/video/impl" // VideoService
// 将对象放入Gin服务的IoC中
_ "github.com/Go-To-Byte/DouSheng/apps/user/http" // UserHandler
_ "github.com/Go-To-Byte/DouSheng/apps/video/http" // VideoHandler
// ...
)
可是在哪里导入呢?当然,我们可以选择在main方法中,编写上面的代码,这没问题。可是如果有很多这样的依赖需要被注入到IoC中,这里这里就有太多这样的导入代码了。
我们可以将这些导包代码打包放入一个包中,之后在main方法中,就可以只导入那一个包了,具体代码下面一起贴。
导包没有问题了,也就是依赖都放入IoC中了,该何时初始化呢?
- Main方法的代码
当然是在main方法中初始化呐,那我直接贴main方法的代码了~
package main
import (
// 在这个包里,统一将依赖放入IoC容器中
_ "github.com/Go-To-Byte/DouSheng/apps/all"
// ...
)
func main() {
// 1、加载配置文件
if err := conf.LoadConfigFromToml(configFile); err != nil {
panic(err)
}
// 2、初始化IoC的依赖
if err := ioc.InitAllDependencies(); err != nil {
return err
}
// 3、通过注册路由及对应的路由函数给Gin,并启动HTTP服务
g := gin.Default()
ioc.Registry(g)
// 4、启动监听TCP连接
g.Run(conf.C().App.HttpAddr())
}
通过一套操作下来,每增加一个模块,Main这里终于不用每次都去 New 对象、注册对应模块的路由了。
增加新模块时,只需要在那边将依赖放入到对应的IoC中,并且在去 all 里面导入所需要的包即可。
- 编写单元测试
而且,有了IoC之后,编写单元测试的代码变得极为容易,比如我给你编写一个:
package impl_test
import (
// 驱动加载所有需要放入IOC的实例
_ "github.com/Go-To-Byte/DouSheng/user_center/common/all"
// ...
)
var (
service user.ServiceServer
)
func TestLogin(t *testing.T) {
should := assert.New(t)
u := user.NewLoginAndRegisterRequest()
// 调用业务方法
token, err := service.Login(context.Background(), u)
if should.NoError(err) {
t.Log(token)
}
}
func init() {
// 加载配置文件(或者可以从环境变量中读取)
if err := conf.LoadConfigFromToml("../../../etc/config.toml"); err != nil {
panic(err)
}
// 初始化IOC容器
if err := ioc.InitAllDependencies(); err != nil {
panic(err)
}
// 从IOC中获取接口实现
service = ioc.GetGrpcDependency(user.AppName).(user.ServiceServer)
}
需要测试哪个模块,直接从IoC中获取对应的依赖,不用直接暴露接口的实现。
至此,我们简易版的IoC已经编写完成了,基本完成了需求。当然,如果你有个性化需求,你还可以继续拓展IoC,比如统一添加一些中间件(传入一些切片)...
具体的代码,可以看看我们的项目中:项目IoC相关代码,是如何使用的。
所以说,IoC只是一种如何编写代码的指导思想,在很多地方都可以使用它来封装代码。如果文章对你有帮助,不妨点赞收藏使用起来~
转载自:https://juejin.cn/post/7214110277120770106