Go语言官方依赖注入工具Wire的使用教程
IT行业相对于一般传统行业,发展更新速度更快,一旦停止了学习,很快就会被行业所淘汰。所以我们需要踏踏实实的不断学习,精进自己的技术,尤其是初学者。今天golang学习网给大家整理了《Go语言官方依赖注入工具Wire的使用教程》,聊聊wire、依赖、注入,我们一起来看看吧!
1. 前言
接触 Golang 有一段时间了,发现 Golang 同样需要类似 Java 中 Spring 一样的依赖注入框架。如果项目规模比较小,是否有依赖注入框架问题不大,但当项目变大之后,有一个合适的依赖注入框架是十分必要的。通过调研,了解到 Golang 中常用的依赖注入工具主要有 Inject 、Dig 等。但是今天主要介绍的是 Go 团队开发的 Wire,一个编译期实现依赖注入的工具。
2. 依赖注入(DI)是什么
说起依赖注入就要引出另一个名词控制反转( IoC )。IoC 是一种设计思想,其核心作用是降低代码的耦合度。依赖注入是一种实现控制反转且用于解决依赖性问题的设计模式。
举个例子,假设我们代码分层关系是 dal 层连接数据库,负责数据库的读写操作。那么我们的 dal 层的上一层 service 负责调用 dal 层处理数据,在我们目前的代码中,它可能是这样的:
// dal/user.go
func (u *UserDal) Create(ctx context.Context, data *UserCreateParams) error {
db := mysql.GetDB().Model(&entity.User{})
user := entity.User{
Username: data.Username,
Password: data.Password,
}
return db.Create(&user).Error
}
// service/user.go
func (u *UserService) Register(ctx context.Context, data *schema.RegisterReq) (*schema.RegisterRes, error) {
params := dal.UserCreateParams{
Username: data.Username,
Password: data.Password,
}
err := dal.GetUserDal().Create(ctx, params)
if err != nil {
return nil, err
}
registerRes := schema.RegisterRes{
Msg: "register success",
}
return ®isterRes, nil
}
在这段代码里,层级依赖关系为 service -> dal -> db,上游层级通过 Getxxx实例化依赖。但在实际生产中,我们的依赖链比较少是垂直依赖关系,更多的是横向依赖。即我们一个方法中,可能要多次调用Getxxx的方法,这样使得我们代码极不简洁。
不仅如此,我们的依赖都是写死的,即依赖者的代码中写死了被依赖者的生成关系。当被依赖者的生成方式改变,我们也需要改变依赖者的函数,这极大的增加了修改代码量以及出错风险。
接下来我们用依赖注入的方式对代码进行改造:
// dal/user.go
type UserDal struct{
DB *gorm.DB
}
func NewUserDal(db *gorm.DB) *UserDal{
return &UserDal{
DB: db
}
}
func (u *UserDal) Create(ctx context.Context, data *UserCreateParams) error {
db := u.DB.Model(&entity.User{})
user := entity.User{
Username: data.Username,
Password: data.Password,
}
return db.Create(&user).Error
}
// service/user.go
type UserService struct{
UserDal *dal.UserDal
}
func NewUserService(userDal dal.UserDal) *UserService{
return &UserService{
UserDal: userDal
}
}
func (u *UserService) Register(ctx context.Context, data *schema.RegisterReq) (*schema.RegisterRes, error) {
params := dal.UserCreateParams{
Username: data.Username,
Password: data.Password,
}
err := u.UserDal.Create(ctx, params)
if err != nil {
return nil, err
}
registerRes := schema.RegisterRes{
Msg: "register success",
}
return ®isterRes, nil
}
// main.go
db := mysql.GetDB()
userDal := dal.NewUserDal(db)
userService := dal.NewUserService(userDal)
如上编码情况中,我们通过将 db 实例对象注入到 dal 中,再将 dal 实例对象注入到 service 中,实现了层级间的依赖注入。解耦了部分依赖关系。
在系统简单、代码量少的情况下上面的实现方式确实没什么问题。但是项目庞大到一定程度,结构之间的关系变得非常复杂时,手动创建每个依赖,然后层层组装起来的方式就会变得异常繁琐,并且容易出错。这个时候勇士 wire 出现了!
3. Wire Come
3.1 简介
Wire 是一个轻巧的 Golang 依赖注入工具。它由 Go Cloud 团队开发,通过自动生成代码的方式在编译期完成依赖注入。它不需要反射机制,后面会看到, Wire 生成的代码与手写无异。
3.2 快速使用
wire 的安装:
go get github.com/google/wire/cmd/wire
上面的命令会在 $GOPATH/bin 中生成一个可执行程序 wire,这就是代码生成器。可以把$GOPATH/bin 加入系统环境变量 $PATH 中,所以可直接在命令行中执行 wire 命令。
下面我们在一个例子中看看如何使用 wire。
现在我们有这样的三个类型:
type Message string
type Channel struct {
Message Message
}
type BroadCast struct {
Channel Channel
}
三者的 init 方法:
func NewMessage() Message {
return Message("Hello Wire!")
}
func NewChannel(m Message) Channel {
return Channel{Message: m}
}
func NewBroadCast(c Channel) BroadCast {
return BroadCast{Channel: c}
}
假设 Channel 有一个 GetMsg 方法,BroadCast 有一个 Start 方法:
func (c Channel) GetMsg() Message {
return c.Message
}
func (b BroadCast) Start() {
msg := b.Channel.GetMsg()
fmt.Println(msg)
}
如果手动写代码的话,我们的写法应该是:
func main() {
message := NewMessage()
channel := NewChannel(message)
broadCast := NewBroadCast(channel)
broadCast.Start()
}
如果使用 wire,我们需要做的就变成如下的工作了:
1.提取一个 init 方法 InitializeBroadCast:
func main() {
b := demo.InitializeBroadCast()
b.Start()
}
2.编写一个 wire.go 文件,用于 wire 工具来解析依赖,生成代码:
//+build wireinject
package demo
func InitializeBroadCast() BroadCast {
wire.Build(NewBroadCast, NewChannel, NewMessage)
return BroadCast{}
}
注意:需要在文件头部增加构建约束://+build wireinject
3.使用 wire 工具,生成代码,在 wire.go 所在目录下执行命令:wire gen wire.go。会生成如下代码,即在编译代码时真正使用的Init函数:
// Code generated by Wire. DO NOT EDIT.
//go:generate wire
//+build !wireinject
func InitializeBroadCast() BroadCast {
message := NewMessage()
channel := NewChannel(message)
broadCast := NewBroadCast(channel)
return broadCast
}
我们告诉 wire,我们所用到的各种组件的 init 方法(NewBroadCast, NewChannel, NewMessage),那么 wire 工具会根据这些方法的函数签名(参数类型/返回值类型/函数名)自动推导依赖关系。
wire.go 和 wire_gen.go 文件头部位置都有一个 +build,不过一个后面是 wireinject,另一个是 !wireinject。+build 其实是 Go 语言的一个特性。类似 C/C++ 的条件编译,在执行 go build 时可传入一些选项,根据这个选项决定某些文件是否编译。wire 工具只会处理有wireinject 的文件,所以我们的 wire.go 文件要加上这个。生成的 wire_gen.go 是给我们来使用的,wire 不需要处理,故有 !wireinject。
3.3 基础概念
Wire 有两个基础概念,Provider(构造器)和 Injector(注入器)
Provider实际上就是生成组件的普通方法,这些方法接收所需依赖作为参数,创建组件并将其返回。我们上面例子的NewBroadCast就是Provider。Injector可以理解为Providers的连接器,它用来按依赖顺序调用Providers并最终返回构建目标。我们上面例子的InitializeBroadCast就是Injector。
4. Wire使用实践
下面简单介绍一下 wire 在飞书问卷表单服务中的应用。
飞书问卷表单服务的 project 模块中将 handler 层、service 层和 dal 层的初始化通过参数注入的方式实现依赖反转。通过 BuildInjector 注入器来初始化所有的外部依赖。
4.1 基础使用
dal 伪代码如下:
func NewProjectDal(db *gorm.DB) *ProjectDal{
return &ProjectDal{
DB:db
}
}
type ProjectDal struct {
DB *gorm.DB
}
func (dal *ProjectDal) Create(ctx context.Context, item *entity.Project) error {
result := dal.DB.Create(item)
return errors.WithStack(result.Error)
}
// QuestionDal、QuestionModelDal...
service 伪代码如下:
func NewProjectService(projectDal *dal.ProjectDal, questionDal *dal.QuestionDal, questionModelDal *dal.QuestionModelDal) *ProjectService {
return &projectService{
ProjectDal: projectDal,
QuestionDal: questionDal,
QuestionModelDal: questionModelDal,
}
}
type ProjectService struct {
ProjectDal *dal.ProjectDal
QuestionDal *dal.QuestionDal
QuestionModelDal *dal.QuestionModelDal
}
func (s *ProjectService) Create(ctx context.Context, projectBo *bo.ProjectCreateBo) (int64, error) {}
handler 伪代码如下:
func NewProjectHandler(srv *service.ProjectService) *ProjectHandler{
return &ProjectHandler{
ProjectService: srv
}
}
type ProjectHandler struct {
ProjectService *service.ProjectService
}
func (s *ProjectHandler) CreateProject(ctx context.Context, req *project.CreateProjectRequest) (resp *
project.CreateProjectResponse, err error) {}
injector.go 伪代码如下:
func NewInjector()(handler *handler.ProjectHandler) *Injector{
return &Injector{
ProjectHandler: handler
}
}
type Injector struct {
ProjectHandler *handler.ProjectHandler
// components,others...
}
在 wire.go 中如下定义:
// +build wireinject
package app
func BuildInjector() (*Injector, error) {
wire.Build(
NewInjector,
// handler
handler.NewProjectHandler,
// services
service.NewProjectService,
// 更多service...
//dal
dal.NewProjectDal,
dal.NewQuestionDal,
dal.NewQuestionModelDal,
// 更多dal...
// db
common.InitGormDB,
// other components...
)
return new(Injector), nil
}
执行 wire gen ./internal/app/wire.go 生成 wire_gen.go
// Code generated by Wire. DO NOT EDIT.
//go:generate wire
//+build !wireinject
func BuildInjector() (*Injector, error) {
db, err := common.InitGormDB()
if err != nil {
return nil, err
}
projectDal := dal.NewProjectDal(db)
questionDal := dal.NewQuestionDal(db)
questionModelDal := dal.NewQuestionModelDal(db)
projectService := service.NewProjectService(projectDal, questionDal, questionModelDal)
projectHandler := handler.NewProjectHandler(projectService)
injector := NewInjector(projectHandler)
return injector, nil
}
在 main.go 中加入初始化 injector 的方法 app.BuildInjector
injector, err := BuildInjector()
if err != nil {
return nil, err
}
//project服务启动
svr := projectservice.NewServer(injector.ProjectHandler, logOpt)
svr.Run()
注意,如果你运行时,出现了 BuildInjector 重定义,那么检查一下你的 //+build wireinject 与 package app 这两行之间是否有空行,这个空行必须要有!见https://github.com/google/wire/issues/117
4.2 高级特性
4.2.1 NewSet
NewSet 一般应用在初始化对象比较多的情况下,减少 Injector 里面的信息。当我们项目庞大到一定程度时,可以想象会出现非常多的 Providers。NewSet 帮我们把这些 Providers 按照业务关系进行分组,组成 ProviderSet(构造器集合),后续只需要使用这个集合即可。
// project.go
var ProjectSet = wire.NewSet(NewProjectHandler, NewProjectService, NewProjectDal)
// wire.go
func BuildInjector() (*Injector, error) {
wire.Build(InitGormDB, ProjectSet, NewInjector)
return new(Injector), nil
}
4.2.2 Struct
上述例子的 Provider 都是函数,除函数外,结构体也可以充当 Provider 的角色。Wire 给我们提供了结构构造器(Struct Provider)。结构构造器创建某个类型的结构,然后用参数或调用其它构造器填充它的字段。
// project_service.go
// 函数provider
func NewProjectService(projectDal *dal.ProjectDal, questionDal *dal.QuestionDal, questionModelDal *dal.QuestionModelDal) *ProjectService {
return &projectService{
ProjectDal: projectDal,
QuestionDal: questionDal,
QuestionModelDal: questionModelDal,
}
}
// 等价于
wire.Struct(new(ProjectService), "*") // "*"代表全部字段注入
// 也等价于
wire.Struct(new(ProjectService), "ProjectDal", "QuestionDal", "QuestionModelDal")
// 如果个别属性不想被注入,那么可以修改 struct 定义:
type App struct {
Foo *Foo
Bar *Bar
NoInject int `wire:"-"`
}
4.2.3 Bind
Bind 函数的作用是为了让接口类型的依赖参与 Wire 的构建。Wire 的构建依靠参数类型,接口类型是不支持的。Bind 函数通过将接口类型和实现类型绑定,来达到依赖注入的目的。
// project_dal.go
type IProjectDal interface {
Create(ctx context.Context, item *entity.Project) (err error)
// ...
}
type ProjectDal struct {
DB *gorm.DB
}
var bind = wire.Bind(new(IProjectDal), new(*ProjectDal))
4.2.4 CleanUp
构造器可以提供一个清理函数(cleanup),如果后续的构造器返回失败,前面构造器返回的清理函数都会调用。初始化 Injector 之后可以获取到这个清理函数,清理函数典型的应用场景是文件资源和网络连接资源。清理函数通常作为第二返回值,参数类型为 func()。当 Provider 中的任何一个拥有清理函数,Injector 的函数返回值中也必须包含该函数。并且 Wire 对 Provider 的返回值个数及顺序有以下限制:
- 第一个返回值是需要生成的对象
- 如果有 2 个返回值,第二个返回值必须是 func() 或 error
- 如果有 3 个返回值,第二个返回值必须是 func(),而第三个返回值必须是 error
// db.go
func InitGormDB()(*gorm.DB, func(), error) {
// 初始化db链接
// ...
cleanFunc := func(){
db.Close()
}
return db, cleanFunc, nil
}
// wire.go
func BuildInjector() (*Injector, func(), error) {
wire.Build(
common.InitGormDB,
// ...
NewInjector
)
return new(Injector), nil, nil
}
// 生成的wire_gen.go
func BuildInjector() (*Injector, func(), error) {
db, cleanup, err := common.InitGormDB()
// ...
return injector, func(){
// 所有provider的清理函数都会在这里
cleanup()
}, nil
}
// main.go
injector, cleanFunc, err := app.BuildInjector()
defer cleanFunc()
更多用法具体可以参考 wire官方指南:https://github.com/google/wire/blob/main/docs/guide.md
4.3 高阶使用
接着我们就用上述的这些 wire 高级特性对 project 服务进行代码改造:
project_dal.go
type IProjectDal interface {
Create(ctx context.Context, item *entity.Project) (err error)
// ...
}
type ProjectDal struct {
DB *gorm.DB
}
// wire.Struct方法是wire提供的构造器,"*"代表为所有字段注入值,在这里可以用"DB"代替
// wire.Bind方法把接口和实现绑定起来
var ProjectSet = wire.NewSet(
wire.Struct(new(ProjectDal), "*"),
wire.Bind(new(IProjectDal), new(*ProjectDal)))
func (dal *ProjectDal) Create(ctx context.Context, item *entity.Project) error {}
dal.go
// DalSet dal注入 var DalSet = wire.NewSet( ProjectSet, // QuestionDalSet、QuestionModelDalSet... )
project_service.go
type IProjectService interface {
Create(ctx context.Context, projectBo *bo.CreateProjectBo) (int64, error)
// ...
}
type ProjectService struct {
ProjectDal dal.IProjectDal
QuestionDal dal.IQuestionDal
QuestionModelDal dal.IQuestionModelDal
}
func (s *ProjectService) Create(ctx context.Context, projectBo *bo.ProjectCreateBo) (int64, error) {}
var ProjectSet = wire.NewSet(
wire.Struct(new(ProjectService), "*"),
wire.Bind(new(IProjectService), new(*ProjectService)))
service.go
// ServiceSet service注入 var ServiceSet = wire.NewSet( ProjectSet, // other service set... )
handler 伪代码如下:
var ProjectHandlerSet = wire.NewSet(wire.Struct(new(ProjectHandler), "*"))
type ProjectHandler struct {
ProjectService service.IProjectService
}
func (s *ProjectHandler) CreateProject(ctx context.Context, req *project.CreateProjectRequest) (resp *
project.CreateProjectResponse, err error) {}
injector.go 伪代码如下:
var InjectorSet = wire.NewSet(wire.Struct(new(Injector), "*"))
type Injector struct {
ProjectHandler *handler.ProjectHandler
// others...
}
wire.go
// +build wireinject
package app
func BuildInjector() (*Injector, func(), error) {
wire.Build(
// db
common.InitGormDB,
// dal
dal.DalSet,
// services
service.ServiceSet,
// handler
handler.ProjectHandlerSet,
// injector
InjectorSet,
// other components...
)
return new(Injector), nil, nil
}
5. 注意事项
5.1 相同类型问题
wire 不允许不同的注入对象拥有相同的类型。google 官方认为这种情况,是设计上的缺陷。这种情况下,可以通过类型别名来将对象的类型进行区分。
例如服务会同时操作两个 Redis 实例,RedisA & RedisB
func NewRedisA() *goredis.Client {...}
func NewRedisB() *goredis.Client {...}
对于这种情况,wire 无法推导依赖的关系。可以这样进行实现:
type RedisCliA *goredis.Client
type RedisCliB *goredis.Client
func NewRedisA() RedicCliA {...}
func NewRedisB() RedicCliB {...}
5.2 单例问题
依赖注入的本质是用单例来绑定接口和实现接口对象间的映射关系。而通常实践中不可避免的有些对象是有状态的,同一类型的对象总是要在不同的用例场景发生变化,单例就会引起数据的错误,不能保存彼此的状态。针对这种场景我们通常设计多层的 DI 容器来实现单例隔离,亦或是脱离 DI 容器自行管理对象的生命周期。
6. 结语
Wire 是一个强大的依赖注入工具。与 Inject 、Dig 等不同的是,Wire只生成代码而不是使用反射在运行时注入,不用担心会有性能损耗。项目工程化过程中,Wire 可以很好协助我们完成复杂对象的构建组装。
更多关于 Wire 的介绍请传送至:https://github.com/google/wire
本篇关于《Go语言官方依赖注入工具Wire的使用教程》的介绍就到此结束啦,但是学无止境,想要了解学习更多关于Golang的相关知识,请关注golang学习网公众号!
Go语言Goroutinue和管道效率详解
- 上一篇
- Go语言Goroutinue和管道效率详解
- 下一篇
- 一文详解Go语言单元测试的原理与使用
-
- 爱听歌的小甜瓜
- 细节满满,码住,感谢老哥的这篇博文,我会继续支持!
- 2023-04-02 05:24:10
-
- 潇洒的铃铛
- 这篇文章出现的刚刚好,作者加油!
- 2023-02-26 00:41:10
-
- 活泼的帽子
- 很好,一直没懂这个问题,但其实工作中常常有遇到...不过今天到这,看完之后很有帮助,总算是懂了,感谢师傅分享文章内容!
- 2023-02-18 01:00:14
-
- 轻松的小松鼠
- 这篇技术文章出现的刚刚好,太全面了,很好,已加入收藏夹了,关注楼主了!希望楼主能多写Golang相关的文章。
- 2023-02-01 01:46:18
-
- Golang · Go教程 | 15小时前 | 并发 · Go教程 · trace.NewTask go tool trace Go runtime/trace 执行跟踪 trace.WithRegion
- Go runtime/trace 怎样按任务标记跟踪区间
- 391浏览 收藏
-
- Golang · Go教程 | 15小时前 | flag · go · Go 命令行参数 flag.TextVar encoding.TextUnmarshaler
- Go flag.TextVar 怎样把自定义类型接入命令行
- 184浏览 收藏
-
- Golang · Go教程 | 17小时前 | 错误处理 · 模板 · Go教程 · html/template · 模板渲染 Go html/template missingkey 模板缺失字段 Execute错误
- Go html/template 缺失字段的严格渲染配置与错误处理
- 371浏览 收藏
-
- Golang · Go教程 | 17小时前 | html · go · 模板 · url · Go html/template 自动转义 URL上下文 template.URL
- Go html/template 怎样避免 URL 上下文误用
- 326浏览 收藏
-
- Golang · Go教程 | 17小时前 |
- Go text/template.ParseFS 怎样加载嵌入文件模板
- 208浏览 收藏
-
- Golang · Go教程 | 17小时前 |
- Go database/sql.NamedArg 怎样给 SQL 参数命名
- 311浏览 收藏
-
- Golang · Go教程 | 17小时前 | HTTP · go · Gin · Gin ShouldBindBodyWith 请求体重复绑定 ShouldBind binding.JSON
- Gin ShouldBindBodyWith 怎么让同一个请求体绑定两次
- 134浏览 收藏
-
- Golang · Go教程 | 18小时前 | go · 文件系统 · Go os.ReadDir Go File.ReadDir Go DirEntry Go 目录遍历
- Go os.ReadDir 怎样高效读取目录条目
- 269浏览 收藏
-
- Golang · Go教程 | 18小时前 | go · Go go/types.Config Go Importer Go 类型检查 types.Check
- Go go/types.Config 怎样给类型检查接入导入器
- 222浏览 收藏
-
- Golang · Go教程 | 18小时前 |
- Go maphash.Hash 怎样用固定种子复现哈希结果
- 341浏览 收藏
-
- Golang · Go教程 | 18小时前 | Go 字节切片 encoding/binary AppendByteOrder
- Go binary.ByteOrder.Append 怎样把整数追加到现有字节切片
- 373浏览 收藏
-
- Golang · Go教程 | 19小时前 | go · IPv6 Go ipv4 netip.ParsePrefix CIDR
- Go netip.ParsePrefix 怎样解析 CIDR 字符串
- 334浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 571次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 615次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 646次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 603次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 421次使用
-
- Golang 官方依赖注入工具wire示例详解
- 2023-02-24 239浏览
-
- 浅析golang的依赖注入
- 2023-01-07 153浏览
-
- 一文详解gomod依赖管理详情
- 2022-12-24 484浏览
-
- go mod 安装依赖 unkown revision问题的解决方案
- 2022-12-29 147浏览
-
- golang使用 gomodule 在公共测试环境管理go的依赖的实例详解
- 2023-01-01 194浏览

