前言
在 JavaWeb 开发领域,Spring 家族早已是霸主地位,其开创式的提出了 IOC(Inversion of Control, 控制反转) 思想,并使用 DI(Dependency Injection, 依赖注入) 实现。
Spring 作为一框 Web 开发领域的框架,之所以叫做框架,是因为它包含了太多的功能,它可以管理对象的整个生命周期,并且提供了非常多的扩展点,为程序开发提供了极大地便利。
而今天要讲的 Wire 它只能叫做工具,因为它实际上就是一个代码生成工具,只不过它可以分析代码中的依赖关系,从而生成代码。
1. Wire 的介绍
wire 是 Google 开源的一款依赖注入与代码生成工具,与 Go 隶属同宗。源码仓库中有大量的测试用例可供参考,同时有较为丰富的文档说明,入门新技术的最快方式就是看官方文档,好在它的文档篇幅较少,毕竟提供的功能有限,不像 Spring 都能出本书了。
Spring 中的依赖注入发生在运行时,也就是程序启动时,是自动的、动态的。程序启动时,我们定义的组件(component)会按照依赖顺序注册进 Spring 容器,当需要使用到某个组件时就可以从容器中取,非常方便。 不过也会有缺点,当注册的组件太多时,程序启动会很慢。
Wire 中的依赖注入发生在编译时,也就是编译代码的阶段,是手动的、静态的。我们需要事先使用wire
命令生成代码,生成后的代码就是一段普通的代码,和我们自己写的差不多,只不过是生成的,省力、省心。这样的做的优点是不会对程序带来性能上的影响,缺点就是缺乏了灵活性。
Spring 中的依赖注入默认按照类型注入,它允许容器中存在多个相同类型的组件,如若如此,可进一步通过名称指定依赖组件;而 Wire 中只有类型注入,官方组织认为如果程序中存在相同类型的依赖,是程序设计上的缺陷。但如果就有那样的场景需要多个相同类型的依赖,在 wire 中该怎么做呢?比如,你的系统需要连接多个 Mysql 数据库,就需要在程序中定义多个数据源,这些数据源对象的类型可都是一样的。此时,我们可以通过 Go 的type
关键字来定义新的类型即可,例如:type DSA DataSource
、type DSB DataSource
…,则DSA
、DSB
在程序中就是不同的类型了。
Spring 中也允许循环依赖,在容器启动时通过三级缓存及延迟加载策略来实现,不过官方是不建议循环依赖的,因为循环依赖就是程序设计上的缺陷,所以 Spring 支持通过设置来禁止循环依赖,以严格的机制来推进程序质量的提升;而 Go 编译器从一开始就是禁止循环依赖的。
这两者相比算是各有千秋吧,JavaWeb 更多侧重于业务,一点点的性能损耗算是毛毛雨,而 Go 更多用于性能要求高但是业务轻的场景。语言没有高低之分,都有它适合的领域,在不同的业务场景下使用合适地解决方案才是应该去思考的。
wire 当前最新版本是v0.5.0
,官方的态度是,这个工具已经很完备了,因此不会再加入新的功能,只会做一些故障修复。
2. Wire 的使用
在命令行工具中运行:go install github.com/google/wire/cmd/wire@latest
,命令会安装在你的$GOPATH/bin
下面,当然需要确保这个路径已经加入到$PATH
中,才能够愉快地使用wire
命令。
wire 中有两个核心概念:提供者(providers)和注入器(injectors)。
2.1 基础篇
2.1.1 基本用法
wire 的主要机制是提供者(provider): 一个可以产生(返回)出值的函数。
首先在main.go
文件中定义出两个函数:NewBoss
和NewCompany
。
1 | package main |
提供者函数名大写因此是可导出的,可以在其他包中使用,当然这并不是 wire 要求的。
接着在wire.go
文件中定义一个普通函数:initCompany
。
1 | //go:build wireinject |
首先,来看initCompany
函数,这是 Go 代码中再普通不过的函数了。函数名随意,是否可导出也随意,返回值更是随意。这都看你的实际需要,你想给函数起什么名字,想要函数返回什么值,只要编写的代码能通过 Go 的编译器就行。
在这里,initCompany
函数就是注入器(injectors),NewBoss
和NewCompany
函数就是提供者(providers)。
不过这里也有一些需要注意的地方:
- 文件的开头写了
//go:build wireinject
,这是 Go 的条件编译,表示当编译命令中带有wireinject
条件时,此文件会被编译。有些地方可能会写作// +build wireinject
,也可能两种同时写。这两种写法的区别可参考Go工具命令。这个编译条件不是必须写在文件顶头,但必须在包名之前,并且必须和包名之间有空行,否则编译时会报错。 initCompany
函数的返回值无实际意义,只是为了能够被编译通过,即使在返回值中给Company
对象赋值,在生成的代码中也会被忽略。
以下三种写法,生成的代码都是一样的,因此,根据实际情况以及个人喜好选择任一种写法即可。
1 | // 写法一,也就是上面的写法 |
有时候第二种写法会生成失败,提示missing return
,关于这个问题,我向开源组织提交了 issue 。
此时将 panic 中的参数换行写即可解决,如下:
1 | func initBar() Bar { |
现在,我们可以在wire.go
文件同级的地方执行wire
命令,当然可以在任何地方执行wire
命令,但需要指定wire.go
文件的路径。接着就会自动在同级目录下生成一个wire_gen.go
文件:
1 | // Code generated by Wire. DO NOT EDIT. |
在这个文件中,首行提示说,这是 wire 生成的代码,不要编辑。
当然不是说不能编辑这个文件,而是最好不要编辑。这就是一个普通的 Go 代码文件,和我们自己写的无任何差异,但是这个文件中的内容会在下一次执行wire
命令时被覆盖。
接着是//go:generate go run github.com/google/wire/cmd/wire
,这是 Go 的工具命令,主要是为方便在没有安装wire
的环境中使用,在命令行中执行go generate
就可以在本地没有安装wire
命令的情况下生成wire_gen.go
文件。
再下面两行,我们可以看到编译条件是!wireinject
,和wire,go
文件中的编译条件是互补的。当我们编译代码时不带wireinject
指令则wire_gen.go
文件会参与编译,wire.go
文件不参与编译。也就是说wire.go
文件只是一个模板文件,作用是为了生成wire_gen.go
文件,程序启动时实际是用到wire_gen.go
文件。而wire_gen.go
文件中只有一个普通的函数initCompany
返回了一个Company
对象。
生成的这段代码就是一段普通的代码,我们也可以手写不用wire
。这里是为了演示做了一个简单的示例。试想一下,如果在我们的程序中,需要的对象很多,依赖关系错综复杂,如果去手写这段代码将会非常痛苦且容易出错,如果后面修改了对象的依赖关系,还得修改这里的代码,简直苦不堪言。那么这个时候wire
的作用就凸显出来的,只需要组织好个对象的依赖关系即可,剩下的就都交给wire
吧。
通过上面这个简单的例子,已经可以初步感受到wire
的魅力了,不必觉得它有多神秘,它不过就是你用来生成代码的工具,本文就是让我们能够全面了解它,并随心所欲的使用它。和 Spring 相比,wire 只是实现了 DI,没有实现 IOC。
所以,在wire.go
文件中,你也可以定义多个函数,在wire_gen.go
文件中都会生成对应的函数。
注意:注入器中需要的提供者函数不能缺少,否则肯定会提示缺少依赖。但提供者函数也不能过多,否则会提示unused provider
。因此提供者函数必须不多不少正正好才行,这也符合 Go 语言的设计哲学。
2.1.2 返回 error
生成的注入器函数都是普通的 Go 代码,由于 Go 支持函数返回多个值,因此提供者函数也是可以产生多个值的,当然也可以返回error
。此时,修改我们的main.go
文件如下:
1 | package main |
修改wire.go
文件如下:
1 | //go:build wireinject |
然后执行wire
命令生成wire_gen.go
文件如下:
1 | // Code generated by Wire. DO NOT EDIT. |
首先,我们修改了NewCompany
提供者函数,使其多返回了一个error
值,那么对应的在wire.go
文件中,initCompany
注入器函数的返回值也要有error
值,并且必须是(*Company, error)
这样的顺序。
此外,我们定义了一个ProviderSet
类型的变量,程序中也定义多个ProviderSet
类型的变量,便于将提供这函数分组。如果多个提供者需要被频繁地组织在一些使用,这样做是非常好用的。
注意:不能在多个ProviderSet
中传入相同的提供者函数,这是不允许的。此外,因为这里是改变了注入器函数的返回值,如果在重新生成前已经在程序中有调用initCompany
函数了,那么wire
生成时就会报错。因为 wire 在生成代码时会先编译你的程序,如果程序编译都无法通过,那么 wire 肯定是无法生成代码的,所以必须确保程序可以被正常编译。
2.2 高级篇
在基础篇中我们已经学会了 wire 的基本用法,接下来将介绍 wire 的高级用法。
2.2.1 接口绑定类型
通常,依赖注入是用于绑定接口的具体实现。在编写代码时提倡面向接口编程,这一点在使用 Spring 开发中尤为突出,通常@AutoWired
的注解是作用在接口之上的,Spring 框架会自动为接口绑定它的实现类对象,这样一来,当我们在程序中为框架提供不同的实现类时,系统某些功能表现上就有所不同。使用接口来降低程序之间的耦合,是程序设计的常用做法。而@AutoWired
的注解的对象,对应在 wire 中就是 Providers,即我们的提供这函数需要返回一个接口类型。
然而,这个习惯并不是 Go 提倡的最佳编程实践,Go 更提倡返回具体类型。那么在 wire 中,既想要提供者返回接口类型,又想要符合 Go 的实践返回具体类型,就需要为接口绑定具体类型了。
创建main.go
文件内容如下:
1 | package main |
创建wire.go
文件内容如下:
1 | //go:build wireinject |
然后执行wire
命令生成wire_gen.go
文件如下:
1 | // Code generated by Wire. DO NOT EDIT. |
这里我们定义了一个Member
接口,以及两个实现类:Staff
和Boss
。
在 ProviderSet 中有一个NewCompany
提供者函数,它需要一个Member
接口的成员依赖。而wire.Bind(new(Member), new(*Staff))
即是提供了一个Member
接口对象,同时为接口绑定了具体类型Staff
,那么相应的就需要有一个Staff
的提供者函数NewStaff
。
关于wire.Bind(iface, to interface{})
方法参数说明:
iface
的值是一个指向了所需接口的指针。这里我们所需的接口是Member
,获取它的指针用new(Member)
;to
的值是一个实现了所需接口类型的指针。这里我们绑定的类型是Staff
,需要注意的是,由于NewStaff
函数的返回值是*Staff
,所以需要获取的是*Staff
的指针用new(*Staff)
;
如果我们想将接口绑定的类型更换为Boss
,那么需要修改 ProviderSet 中的值如下:
1 | var ProviderSet = wire.NewSet( |
然后再次执行wire
命令,重新生成wire_gen.go
文件:
1 | // Code generated by Wire. DO NOT EDIT. |
2.2.2 提供者构建结构体
可以使用提供者函数来构建结构体。使用wire.Struct()
函数可以构建一个结构体,并且告诉注入器函数,结构体的哪些字段是需要被注入的。注入器函数将会按照结构体的各个字段类型来注入。
创建main.go
文件内容如下:
1 | package main |
创建wire.go
文件内容如下:
1 | //go:build wireinject |
然后执行wire
命令生成wire_gen.go
文件如下:
1 | // Code generated by Wire. DO NOT EDIT. |
这里我们定义了一个Company
,其中包含两个字段:*Boss
和*Staff
。
在ProviderSet
中不在需要NewCompany
提供者函数,而是使用wire.Struct(new(Company), "boss", "staff")
来代替。我们指定了需要注入的两个字段名称boss
和staff
,那么相应的就需要有这两个提供者函数NewBoss
和NewStaff
。
关于Struct(structType interface{}, fieldNames ...string)
方法参数说明:
structType
的值是一个指向了所需结构体的指针。这里我们所需结构体是Company
,获取它的指针用new(Company)
;fieldNames
的值是结构体的字段名,需要注意名称大写小,名称必须一致才行。写了哪些字段名,就会注入哪些字段,如果需要注入全部字段也可用"*"
来简化写法;
如果initCompany
函数返回的是Company
:
1 | //go:build wireinject |
那么生成的wire_gen.go
文件如下:
1 | // Code generated by Wire. DO NOT EDIT. |
有时候我们简化写为:wire.Struct(new(Company), "*")
,但又想要阻止某些字段被注入,可以给不想被注入的字段加上标签`wire:"-"
`,如下:
1 | type Company struct { |
然后执行wire
命令重新生成wire_gen.go
文件如下:
1 | // Code generated by Wire. DO NOT EDIT. |
如果此时在wire.Struct(new(Company), "staff")
中指定注入staff
则wire
命令会报错。
2.2.3 绑定值
有时候,需要将一个基本类型值(通常是 nil)绑定到接口,而不必为这种一次性的事情创建一个提供者函数,可以使用wire.Value()
和wire.InterfaceValue()
向ProviderSet
添加值表达式。
创建main.go
文件内容如下:
1 | package main |
创建wire.go
文件内容如下:
1 | //go:build wireinject |
然后执行wire
命令生成wire_gen.go
文件如下:
1 | // Code generated by Wire. DO NOT EDIT. |
这里我们定义了一个Company
,其中包含了两个字段:*Boss
和Memeber
。其中Member
是一个接口,Staff
实现了这个接口。
我们在main.go
中没有定义任何NewXXX
提供者函数,而是使用了wire.Value()
、wire.InterfaceValue()
和wire.Struct()
这三个函数完成了依赖注入。wire.Struct()
我们在上一节中已经说过,这里主要说wire.Value()
和wire.InterfaceValue()
这两个函数。
我们回忆一下,在没有使用wire.Value()
函数的时候,我们需要定义了一个NewBoss
提供者函数,因此wire.Value()
函数的作用就是可以省略定义这种一次性的提供者函数。
然后是在没有使用wire.InterfaceValue()
函数的时候,我们需要定义一个NewStaff
提供者函数,以及在ProviderSet
中写wire.Bind(new(Member),new(*Staff))
,因此wire.InterfaceValue()
作用就是代替了前面这两处。
简单来说,wire.Value()
和wire.InterfaceValue()
更像是提供的一种语法糖函数。
注意,wire.Value()
中的参数不能是接口,如果提供的是指针类型,则需要写作&Boss{}
,若写作new(Boss)
会报错,提示:argument to Value is too complex
。
2.2.4 结构体字段作为提供者
有时候,我们想把结构体中的字段值作为提供者,不必定义一个GetXXX
提供者函数将结构体中的字段暴露出来,而是使用wire.FieldsOf()
函数。
创建main.go
文件内容如下:
1 | package main |
创建wire.go
文件内容如下:
1 | //go:build wireinject |
然后执行wire
命令生成wire_gen.go
文件如下:
1 | // Code generated by Wire. DO NOT EDIT. |
这里我们先是使用wire.Value
得到一个Competitor
提供者,接着使用wire.FieldsOf
将Competitor
中的staff
字段值作为一个提供者,最后使用wire.Struct
注入了staff
字段。
同wire.Struct
类似,wire.FieldsOf
也可以将多个字段作为提供者。
2.2.5 清理函数
有时候,提供者在返回值的同时,可能需要有清理数据、关闭资源等操作,就会需要返回一个闭包函数来处理。例如,数据库连接池关闭资源、文件对象关闭资源等操作,当程序中发生错误时,需要调用清理函数执行相应的动作。
创建main.go
文件内容如下:
1 | package main |
创建wire.go
文件内容如下:
1 | //go:build wireinject |
然后执行wire
命令生成wire_gen.go
文件如下:
1 | // Code generated by Wire. DO NOT EDIT. |
这里我们定义了三个类型:Staff
、Manager
、Boss
,它们三个层层依赖,并且各自都有清理函数。而Company
依赖了Boss
并且还会返回一个error
。
那么,在我们的注入器函数initCompany
中就需要返回(*Company, func(), error)
。即任何一个提供者函数返回了清理函数,则注入器函数必需要返回一个清理函数;即任何一个提供者函数返回了error
,则注入器函数必需要返回一个error
。
所以,注入器函数initCompany
的返回值可能有四种情况:
- 返回
*Company
; - 返回
(*Company, error)
; - 返回
(*Company, func())
; - 返回
(*Company, func(), error)
;
注意,当注入器函数返回多值时,一定要按照顺序定义,否则wire
命令报错。
从wire_gen.go
文件中我们可以看到,当注入器中发生error
时,会自动调用清理函数。当多个提供者都有清理函数时,会将其聚合,最终会按照对象的依赖层级,从外层对象的清理函数到内层对象的清理函数,依次执行。
3. wire 最佳实践
3.1 可区分的类型
有时候,我们需要注入一个基本类型如string
,就需要为string
创建一个新的类型来避免与其他提供者发生冲突。
创建main.go
文件内容如下:
1 | package main |
创建wire.go
文件内容如下:
1 | //go:build wireinject |
然后执行wire
命令生成wire_gen.go
文件如下:
1 | // Code generated by Wire. DO NOT EDIT. |
这里我们定义了Staff
和Boss
两个对象,其中都含有一个name
属性,并且属性类型相同。如果直接将name
的类型定义为string
,则在执行wire
命令时会报错。
前面我们说过,wire
是靠类型注入的,相同的类型则会导致冲突而无法完成注入,所以我们需要重新定义string
来区分类型,而BossName
和StaffName
虽然底层类型都是string
,但实际上他们是不同的类型(type),因此可以正常完成注入。
3.2 可选项结构体
有时候,一个提供者函数中包含了比较多的依赖项,则可以创建一个可选项结构体与这个提供者函数配对。
创建main.go
文件内容如下:
1 | package main |
创建wire.go
文件内容如下:
1 | //go:build wireinject |
然后执行wire
命令生成wire_gen.go
文件如下:
1 | // Code generated by Wire. DO NOT EDIT. |
这里我们本意是想构建一个对象(Company),而Company
中有若干个字段(即岗位),有些字段(岗位)在初期没有,但在后续(随着公司的发展),将会增加新的字段(岗位),那就需要对Company
进行修改,这种修改有较大的侵入性,当我们的系统较为庞大时,构建的这个Company
对象将会很复杂,这种做法很不利于维护。
因此,我们创建一个CompanyOptions
对象,即通过一个中间对象将Company
与其中的属性做了解耦,提升了Company
的可维护性。
3.3 在库中的提供者集合
有时候,我们会在库(即Library,指从外部引入的三方库,如wire、gin、gorm等)中创建提供者集合(ProviderSet)便于开发者使用。当我们需要更改ProviderSet
时,只有以下操作才能不破坏兼容性:
- 更改
ProviderSet
中的一个提供者函数的实现,但不能更改这个提供者函数的输出类型,并且更改后一定要使用wire
命令重新生成代码才能生效。 - 向
ProviderSet
中引入一个新的提供者,这个提供者输出的类型(type)必须是新增(即对一个已存在的类型 type 创建新的类型)的,这个新类型需要是之前都不存在的。否则很可能在注入器中已经包含了这个输出类型,就会造成冲突,导致wire
命令生成代码会失败。
所有的这些更改都是不安全的:
ProviderSet
中的提供者函数需要依赖一个新的输入类型;- 从
ProviderSet
中移除了一个输出类型(即移除了一个提供者); - 向
ProviderSet
中增加了一个输入类型(即新增了一个提供者),而这个类型是已存在的;
因此,在库中对外提供的ProviderSet
中,所有提供者所产生的输出类型一定要仔细挑选。一般来说,库中对外提供的ProviderSet
中的提供者数量尽可能地少,并且提供者函数需要依赖的输入,以及其产生的输出,也要尽可能地少。通常,在库中对外提供的ProviderSet
中只包含单个提供者,这个提供者返回接口类型,并且使用wire.Bind()
来绑定具体实现类型。避免创建一个较大的ProviderSet
,以降低程序中发生冲突的可能性。
为了说明这个问题,试着想象一下,在你库中的ProviderSet
提供了一个用于 Web Api 服务的客户端(即提供者函数),这个客户端当然需要依赖一个*http.Client
以发送 HTTP 请求。如果你在ProviderSet
中同时也提供了一个*http.Client
作为客户端(提供者函数)的输入(参数),这样做似乎挺好的,可是如果每个库都这样做呢?多个ProviderSet
都提供了*http.Client
就会造成冲突。因此,在你库中的ProviderSet
应该只包含客户端,让*http.Client
从外部提供,作为注入器函数的输入。
3.4 wire mock
有时候,我们想要对某些依赖进行 mock,在 Spring 中是很方便的,但在 wire 中怎么做呢?mock 的思想一般是依赖接口,替换不同的实现可达到 mock 效果,在 Java 中花样比较多,有通过反射实现的,通过字节码插桩的,通过类加载替换的。因为语言机制的不同,Java 在程序编译到运行的各个时期都可以做文章,实际上 Go 也可以在编译期到运行期做文章,只是感觉花样比 Java 少。 Go 没有类似 Java 的类加载过程,一般是通过代码编译、反射机制来实现 mock。而 Go 的反射机制不似 Java 那般强大,且 Go 的反射会带来很大的性能损耗。老实说,使用 Go 如果不注重性能的话,那和用 Java 又有什么分别呢。
wire mock 有两种方式:
- 方式 A:传入 mock 给注入器。创建一个测试专用的注入器函数,将所有的 mock 变量都作为注入器函数的参数,这些变量的类型必须是正在 mock 对象的接口类型。
wire.Build
中不包含返回 mock 类型的提供者函数就不会造成冲突,所以就需要定义一个不包含 mock 类型的ProviderSet
。 - 方式 B:注入器返回 mock。创建一个测试专用的注入器函数,这个函数返回一个新的结构类型,这个结构中包含原本需要的结构以及所需的 mock 提供者,并使用
wire.Bind
将 mock 结构与接口绑定。
下面使用示例代码来说明。
3.4.1 方式 A
创建main.go
文件内容如下:
1 | package main |
创建wire.go
文件内容如下:
1 | //go:build wireinject |
然后执行wire
命令生成wire_gen.go
文件如下:
1 | // Code generated by Wire. DO NOT EDIT. |
这里我们创建了两个注入器函数initCompany
和initMockCompany
,它们有相同的返回值*Company
,在Company
结构中持有一个Boss
接口类型的对象。不同的是initMockCompany
函数需要从外部传入这个Boss
接口对象,因此在使用中我们传入一个*FakerBoss
,便达到了 mock 的效果。
3.4.2 方式 B
创建main.go
文件内容如下:
1 | package main |
创建wire.go
文件内容如下:
1 | //go:build wireinject |
然后执行wire
命令生成wire_gen.go
文件如下:
1 | // Code generated by Wire. DO NOT EDIT. |
这里我们也是创建了两个注入器函数initCompany
和initMockCompany
,它们都不需要从外部传入参数。但它们有不同的返回值,initCompany
的返回值是*Company
,而initMockCompany
的返回值是*MockCompany
。再来看看MockCompany
的结构,其中包含了一个*Company
和一个所需 mock 对象的实际类型。在initMockCompany
函数中,这个 mock 对象会被实际注入,便达到了 mock 的效果。
现在我们总结一下 wire mock 的两种方式,方式 A
和方式 B
的核心差异就是:方式 A
中 mock 需要使用者传入,方式 A
中自带 mock,使用者无需操心传入。
总结
以上就是对 wire 的详细介绍。下面来总结一下常见地需要注意的点:
- wire 命令执行成功的前提是你的代码可以通过编译。有些时候会存在比较隐晦的问题,比如你的代码中存在循环依赖(即 package a 中依赖了 package b,同时 package b 中又依赖了 package a),这时候 IDE(如 GoLand) 是不会有错误提示的,执行 wire 也肯定会失败。有时候 wire 提示
no provider found for *invalid type
就可能是这个原因,此时先用go build
编译代码,看错误提示是哪里出现了循环依赖,修改后再次执行go build
直到编译通过,这时再执行wire
。当然在排除掉代码本身的问题后,这个时候执行wire
还是可能会失败,失败的原因则是使用 wire 不当导致的; - 如果修改了注入器函数的名称或出/入参,需要将原本调用注入器函数的代码给注释掉,否则
wire
执行失败,因为此时go build
肯定是不通过的; - 当我们在注入器函数中使用
panic()
的写法,注意最好的是将panic()
中换行写,否则wire
执行可能会出现missing return
这样的错误提示; wire.Value()
中第二个参数如果是指针类型,只能写作&XXX{}
,若写作new(XXX)
执行wire
会提示错误:argument to Value is too complex
;- 一个或多个
ProviderSet
中不能出现返回相同类型的提供者;
Tip:本文完整示例代码已上传至 Gitee
后话
Go 的生态中,除了 wire 这样的静态注入工具,也有动态注入工具,如:uber 开源的 dig、inject。它们都是使用反射来实现的,灵活性是有了,但是性能损耗很大。 也不是说这就一定不好,任何工具都有合适它的地方,所以它们才会被创造出来,还是那句话:在合适的场景选择合适地解决方案才是应该去思考的。