告别“意大利面条”代码:我是如何在Go项目里做依赖注入的(含源码)

40 阅读6分钟

你的main.go是不是也像一个“大泥球”,塞满了各种NewService?当依赖关系越来越复杂,你是否也曾被对象的创建顺序搞得头昏脑胀?本文将以开源项目easyms.golang为例,分享一次从尝试google/wire到回归“手动DI”的完整思考过程。我们将深入探讨依赖注入的核心思想,并最终沉淀出一套清晰、可维护、无“黑魔法”的Go依赖注入实践方案。

大家好,我是Louis。

今天我们继续优化 EasyMs 架构, 来聊一个能显著提升Go项目工程化水平的话题:依赖注入(Dependency Injection, DI)。很多初学者对依赖注入不够重视,其实他真不难,而且是你项目走向工程化的必经之路。本来一种方式就可以完成的,我今天分别用手动和自动两种方式来全面揭开它神秘的面纱。

很多从其他语言转过来的开发者,初次接触Go时都会有些困惑:“Go没有Spring、Autofac那样的DI框架,我该怎么管理我的对象依赖?”。 记得好8年前的一个SignalR的项目,因为开发人员使用Autofac不当,一上线服务器压力就扛不住了,也因此我每次项目开发中对依赖注入部分多一点关注。

上一篇文章的评论区有位朋友提出了一个”依赖DY“的案例问题,长期处理可能需要重新进行框架治理(重新分层),短期的处理就是清理依赖关系,将构建和接口应用分离,再加上Mock,感兴趣的朋友可以查看上一篇文章。

我们先来看看不使用依赖注入的代码,很多Go项目的main函数,就成了下面这样:

func main() {
    // 一个典型的“意大利面条”式 main 函数
    cfg := config.Load()
    logger := logger.New(cfg.Log)
    
    db, err := database.New(cfg.DB)
    if err != nil { /* ... */ }

    userRepo := repository.NewUserRepo(db)
    orderRepo := repository.NewOrderRepo(db)

    authSvc := service.NewAuthService(userRepo)
    orderSvc := service.NewOrderService(orderRepo, logger)

    authHandler := handler.NewAuthHandler(authSvc)
    orderHandler := handler.NewOrderHandler(orderSvc)

    engine := gin.New()
    engine.POST("/login", authHandler.Login)
    engine.POST("/orders", orderHandler.Create)
    
    engine.Run()
}

这个main函数就像一个“大总管”,负责所有对象的创建和组装。项目初期还好,一旦服务变复杂,这里的代码就会迅速膨胀成一坨难以维护的“意大利面条”。

easyms.golang项目中,我决定对这个问题发起挑战。我分别在两个核心服务中尝试了两种截然不同的DI方案,好让大家真正的明白如何做依赖注入:

  1. order-svc(订单服务):采用手动编写DI容器的方案。
  2. user-svc(用户服务):采用**google/wire自动生成**的方案。

今天,我就来复盘一下这两次实战的得失。

方案一:手动DI容器(以order-svc为例)

这是最朴素、最直接的方案。既然main.go太乱,那我们就把初始化的逻辑抽离出来,放到一个专门的文件里。

我们创建了一个app.go文件,它就是我们手动的“依赖注入容器”。

1. 定义App结构体

首先,我们定义一个App结构体,它持有所有我们需要管理生命周期的核心组件。

// internal/services/order/cmd/ordersvc/app.go
type App struct {
    engine       *gin.Engine
    ds           *discovery.Discovery
    relayService *service.RelayService
    // ...
}

2. 编写InitializeApp函数

然后,我们编写一个InitializeApp函数,在这里集中处理所有的初始化逻辑。

func InitializeApp(serverName string, env string) (*App, func(), error) {
    // --- 1. 配置加载 ---
    appConfig, err := provideAppConfig(...)
    if err != nil { return nil, nil, err }

    // --- 2. 基础组件初始化 ---
    dbase, err := provideDatabase(appConfig)
    // ...

    // --- 3. 服务层初始化 ---
    orderService := service.NewOrderService(dbase)
    // ...

    // --- 4. 构建 App ---
    app := &App{ ... }

    // --- 5. 定义清理函数 ---
    cleanup := func() {
       // 按相反顺序关闭资源
        relayService.Stop()
        // ...
    }

    return app, cleanup, nil
}

3. 简洁的main.go

最终,order-svcmain.go变得非常清爽:

func main() {
    // 调用我们手动编写的初始化函数
    app, cleanup, err := InitializeApp("order-svc", "dev")
    if err != nil {
       panic(err)
    }
    defer cleanup()

    app.engine.Run(...)
}

评价:这种方案清晰、可控、无黑魔法。所有的依赖关系和初始化顺序都一目了然,非常适合中小型项目。

方案二:google/wire自动注入(以user-svc为例)

手动写InitializeApp虽然清晰,但如果依赖关系非常复杂,写起来也很累。于是,我在user-svc中尝试了Google官方的wire工具。

wire是一个编译时的DI工具。你只需要告诉它“零件”有哪些,它就能自动帮你把“车”组装好。

1. 编写wire.go,定义“蓝图”

我们不再手写初始化逻辑,而是定义一个ProviderSet

// internal/services/user/cmd/usersvc/wire.go
//go:build wireinject
// +build wireinject

package main

import "github.com/google/wire"

var providerSet = wire.NewSet(
    // 告诉 wire 如何创建各个组件
    config.InitAppConfigStore,
    provideDiscovery,
    provideAppConfig,
    provideDatabase,
    service.NewUserService,
    handles.NewUserHandler,
    NewApp,
)

// InitializeApp 是 wire 的入口点
func InitializeApp(inputs ConfigInputs) (*App, func(), error) {
    // wire 会自动填充这个函数体
    wire.Build(providerSet)
    return nil, nil, nil
}

2. 生成代码

运行wire命令后,它自动生成了一个wire_gen.go文件。打开一看,里面的代码逻辑和我们在order-svc中手写的一模一样!wire帮我们完成了那些繁琐的组装工作。

3. 同样的main.go

user-svcmain.go代码与order-svc几乎完全一致,也是直接调用InitializeApp

评价wire方案自动化程度高、编译时检查依赖。虽然初次配置(如处理vendor、构建标签)有些门槛,但一旦跑通,对于大型项目来说,它能极大地减少重复劳动。

总结:我的Go DI实践建议

经过这次对比实战,我沉淀出了一套在Go项目中进行依赖注入的实践建议:

  1. 拥抱接口,面向抽象编程:这是DI的基础。无论用不用工具,你的业务逻辑都应该依赖于接口(如db.Database),而不是具体实现。

  2. 坚持使用构造函数:为你的每一个结构体提供一个New...开头的构造函数,明确声明它的依赖。这是让依赖关系变得清晰的关键。

  3. 根据项目规模选择方案

    • 中小型项目:推荐使用手动DI容器(如order-svc)。它足够简单,没有学习成本,且完全可控。
    • 大型/复杂项目:推荐使用**google/wire**(如user-svc)。当依赖关系网变得庞大时,wire的静态分析和自动生成能力将成为你的得力助手。

无论选择哪种方式,核心目标都是一样的:main.go回归纯粹,让依赖关系清晰可见。

如果你想深入了解这两种方案的完整代码实现,欢迎访问项目的源码地址,给我一个 Star ⭐!


读者互动:

如果觉得这篇文章对你有用,欢迎点赞、推荐支持。

在你的Go项目中,你是如何管理依赖注入的?你更倾向于手动控制还是自动生成?欢迎在评论区分享你的看法!👇