Golang 编程思维和工程实战

Posted 腾讯技术工程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Golang 编程思维和工程实战相关的知识,希望对你有一定的参考价值。



Golang 的一些编程思维和思想,以及总结一些常见的优雅编程实战技巧。

作者:allendbwu,腾讯 PCG 后台开发工程师

一 Golang 编程思维

首先,我们先来看下最基本的,就是 Golang 的学习技巧,比如通读 Golang 的一些好的文章:

  • Frequently Asked Questions (FAQ)
  • FAQ 的中文翻译
  • Go 精华文章列表
  • Go 相关博客列表
  • Go Talks
  • 要通读 golang 官方的编码规范,主要是要参考官方的 CodeReviewComments 和 Effective Go 这两篇官方文章,真的非常推荐必须要好好的看完、看懂这两篇文章(英文不好的同学可以看中文翻译文档),然后按照官方编码规范来具体 coding,主要是能够在具体的编码中有迹可循。

    参考业界大牛们的代码,主要是看一些开源的优质的项目,比如 Google 他们这帮人自己搞的 Kubernetes、Istio,还有一些好的项目如 Docker、CoreDNS、etcd 等等。

  • 项目基本架构的组织
  • 代码基本的编码封装
  • 代码的基本原则规范
  • 并发的设计思想
  • 面向对象编程的设计思想
  • 可扩展性的设计思想
  • 然后就是实践,实实在在的跑一些代码示例,可以自己建立一个 base-code 的项目,里面就是你的各种示例,然后进行一些修改、执行。具体的代码示例可以从官方文档上来,推荐Go by Example,里面有大量非常好的例子。也可以自己网上随便搜下,重要的自己要修改并执行,查看和分析结果:Go 101

    其次,要理解 Golang 编程思维,首先要理解 Golang 这门语言的创始初衷,初衷就是为了解决好 Google 内部大规模高并发服务的问题,主要核心就是围绕高并发来开展;并且同时又不想引入面向对象那种很复杂的继承关系。首先,就是可以方便的解决好并发问题(包括高并发),那么就需要有并发思维,能够并发处理就通过并发来进行任务分配

  • 这个就是涉及到了 context、 goroutine、channel(select);
  • 创建大量 goroutine, 但是需要能通过 context、 channel 建立 "父子"关系,保证子任务可以能够被回收、被主动控制(如 杀死)。
  • 再者,面向对象编程思想,利用好 interface、 struct 来实现继承、多态的用法:

  • struct 匿名组合来实现继承;
  • terface 和 struct 来实现多态;
  • interface 定义接口,尽可能的保持里面的方法定义简单,然后多个 interface 进行组合。
  • 然后,理解 Golang 语言本身的一些特性: - 强类型,语法上要注意处理;- GC,实际中要观察 GC 日志并分析;- 注意语法语义尽可能的简单、保持各种类型定义尽可能精简。

    最后,从 Golang 社区的一些最佳实践来看,Golang 的各种组件需要尽可能的精简。

  • Golang 中用好的一些开源组件库,都是比较轻量级的,然后可以各自随意组合来达到最佳实践。
  • 我们自己进行组件封装、模块封装的时候,也是保持这个原则,尽可能的精简,然后使用方进行组合。
  • 二、Golang 高级编码技巧

    1 优雅的实现构造函数编程思想

    /*
    一个更为优雅的构造函数的实现方式
    参考:
    https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html
    通过这个方式可以方便构造不同对象,同时避免了大量重复代码
    */


    package main
    import (
     "fmt"
     "time"

     "golang.org/x/net/context"
    )

    type Cluster struct 
     opts options


    type options struct 
     connectionTimeout time.Duration
     readTimeout       time.Duration
     writeTimeout      time.Duration
     logError          func(ctx context.Context, err error)


    // 通过一个选项实现为一个函数指针来达到一个目的:设置选项中的数据的状态
    // Golang函数指针的用法
    type Option func(c *options)

    // 设置某个参数的一个具体实现,用到了闭包的用法。
    // 不仅仅只是设置而采用闭包的目的是为了更为优化,更好用,对用户更友好
    func LogError(f func(ctx context.Context, err error)Option 
     return func(opts *options) 
      opts.logError = f
     


    // 对关键数据变量的赋值采用一个方法来实现而不是直接设置
    func ConnectionTimeout(d time.Duration) Option 
     return func(opts *options) 
      opts.connectionTimeout = d
     


    func WriteTimeout(d time.Duration) Option 
     return func(opts *options) 
      opts.writeTimeout = d
     


    func ReadTimeout(d time.Duration) Option 
     return func(opts *options) 
      opts.readTimeout = d
     


    // 构造函数具体实现,传入相关Option,new一个对象并赋值
    // 如果参数很多,也不需要传入很多参数,只需要传入opts ...Option即可
    func NewCluster(opts ...Option) *Cluster 
     clusterOpts := options
     for _, opt := range opts 
      // 函数指针的赋值调用
      opt(&clusterOpts)
     

     cluster := new(Cluster)
     cluster.opts = clusterOpts

     return cluster


    func main() 

     // 前期储备,设定相关参数
     commonsOpts := []Option
      ConnectionTimeout(1 * time.Second),
      ReadTimeout(2 * time.Second),
      WriteTimeout(3 * time.Second),
      LogError(func(ctx context.Context, err error) 
      ),
     

     // 终极操作,构造函数
     cluster := NewCluster(commonsOpts...)

     // 测试验证
     fmt.Println(cluster.opts.connectionTimeout)
     fmt.Println(cluster.opts.writeTimeout)


    除了构造函数这个思想之外,还有一个思想,就是我们要善于利用 struct 封装对象方法,然后再 new 一个对象出来,如下:

    type Cluster struct 
     opts options


    func NewCluster(opts ...Option) *Cluster 
       ....

     cluster := new(Cluster)
     cluster.opts = clusterOpts

     return cluster

    2 优雅的实现继承编程思想

    Golang 里面没有 C++ 、Java 那种继承的实现方式,但是,我们可以通过 Golang 的匿名组合来实现继承,这里要注意,这个是实际编程中经常用到的一种姿势。具体实现就是一个 struct 里面包含一个匿名的 struct,也就是通过匿名组合,这最基础的基类就是一个 struct 结构,然后定义相关成员变量,然后再定义一个子类,也是一个 struct,里面包含前面的 struct,即可实现继承。

    示例代码如下,代码里面有详细的解释:

    package main
    import (
     "fmt"
    )

    // 【基类】
    //定义一个最基础的struct类MsgModel,里面包含一个成员变量msgId
    type MsgModel struct 
     msgId   int
     msgType int


    // MsgModel的一个成员方法,用来设置msgId
    func (msg *MsgModel) SetId(msgId int) 
     msg.msgId = msgId


    func (msg *MsgModel) SetType(msgType int) 
     msg.msgType = msgType


    //【子类】
    // 再定义一个struct为GroupMsgModel,包含了MsgModel,即组合,但是并没有给定MsgModel任何名字,因此是匿名组合
    type GroupMsgModel struct 
     MsgModel

     // 如果子类也包含一个基类的一样的成员变量,那么通过子类设置和获取得到的变量都是基类的
     msgId int


    func (group *GroupMsgModel) GetId() int 
     return group.msgId


    /*
    func (group *GroupMsgModel) SetId(msgId int) 
     group.msgId = msgId

    */


    func main() 
     group := &GroupMsgModel

     group.SetId(123)
     group.SetType(1)

     fmt.Println("group.msgId =", group.msgId, "\\tgroup.MsgModel.msgId =", group.MsgModel.msgId)
     fmt.Println("group.msgType =", group.msgType, "\\tgroup.MsgModel.msgType =", group.MsgModel.msgType)


    3 优雅的实现虚多态编程思想

    面向对象编程中,我们很多情况下,都会定义一个虚基类,然后利用多态去实现各种相似的场景或者说任务。

    Golang 里面可以通过 interface + struct 来实现虚基类的用法。interface 用来定义一个 "虚基类",然后一个 struct 结构定义,用来实现这个 interface 中定义的方法,并且可以有多个类似的 struct 来实现这个 interface,只要实现了这个 interface 中定义的方法即可。这也是典型的多态的一种编程思想,也就是说 Golang 通过接口去实现了多态。

    具体流程如下,这个是我实际项目(大型 IM 架构)中的实现方式:

    1. 定义一个 interface 接口 MsgModel,包含了一些方法,这个就相当于 "虚基类"

      type MsgModel interface 
          Persist(context context.Context, msg interfacebool
          PersistOnSensitive(context context.Context, session_type, level, SensitiveStatus int32, msg interfacebool

    2. 定义一个类型 msgModelImpl struct,用来实现上面的 interface 接口

      定义一个struct用来实现接口类型
      type msgModelImpl struct


      定义一个变量MsgModelImpl等于msgModelImpl,相当于可以通过MsgModelImpl来调用msgModelImpl的成员
      var MsgModelImpl = msgModelImpl

      实现接口的两个方法
      func (m msgModelImpl) Persist(context context.Context, msgIface interface) bool 
      // 具体实现省略


      func (m msgModelImpl) UpdateDbContent(context context.Context, msgIface interface) bool 
      // 具体实现省略

    1. 再定义一个 struct 类型的 msgService,包含上述接口类型 MsgModel,相当于组合了。这样的话,这个类型就需要要实现接口方法。

      type msgService struct 
         msgModel MsgModel

    1. 再定义一个变量 MsgService,首字母大写,并且赋值为 msgService 对象,同时给成员 msgModel 赋值为上述已经实现了接口的 struct 对象 MsgModelImpl。

      将上述已经实现接口类型的类型(MsgModelImpl) 赋值给此变量(此变量并且要是包含了接口类型的类型), 然后这个变量就可以供外部调用
      var  MsgService = msgService
              msgModel:  MsgModelImpl,

    2. 这样就全部实现了,后面只要通过 MsgService 中的接口方法就可以调用 interface 中定义的方法

    3. 注意,定义个 MsgService,里面的成员变量 msgModel 赋值为 MsgModelImpl 的目的是为了做封装,对外暴露接口的都是 MsgService,隐藏了内部具体的 MsgModelImpl 实现。
    1. 小结:
  • MsgModel 是一个 interface
  • interface 是一组抽象方法的集合,interface 未具体实现的方法,仅包含方法名参数返回值的方法
  • msgModelImpl 是一个 struct,它实现了 MsgModel 这个 interface 的所有方法
  • 如果实现了 interface 中的所有方法,即该类/对象就实现了该接口
  • MsgModelImpl 是 msgModelImpl 这个 struct 的对象
  • msgService 是一个 struct,它包含了 MsgModel,相当于组合
  • MsgService 是 msgService 这个 struct 的对象,并对成员变量赋值
  • 后面就通过 MsgService 对外提供服务,隐藏内部具体的 MsgModelImpl 实现。
  • 4 Golang 的 model service 模型【类 MVC 模型】

    在一个项目工程中,为了使得代码更优雅,需要抽象出一些模型出来,同时基于 C++面向对象编程的思想,需要考虑到一些类、继承相关。在 Golang 中,没有类、继承的概念,但是我们完全可以通过 struct 和 interface 来建立我们想要的任何模型。在我们的工程中,抽象出一种我自认为是类似 MVC 的模型,但是不完全一样,个人觉得这个模型抽象的比较好,容易扩展,模块清晰。对于使用 java 和 php 编程的同学对这个模型应该是再熟悉不过了,我这边通过代码来说明下这个模型

    1. 首先一个 model 包,通过 interface 来实现,包含一些基础方法,需要被外部引用者来具体实现

      package model

      // 定义一个基础model
      type MsgModel interface 
       Persist(context context.Context, msg interfacebool
       UpdateDbContent(context context.Context, msgIface interfacebool
       GetList(context context.Context, uid, peerId, sinceMsgId, maxMsgId int64, count int) (interfacebool)]

    2. 再定义一个 msg 包,用来具体实现 model 包中 MsgModel 模型的所有方法

      package msg
      type msgModelImpl struct
      var MsgModelImpl = msgModelImpl
      func (m msgModelImpl) Persist(context context.Context, msgIface interface) bool 
        // 具体实现


      func (m msgModelImpl) UpdateDbContent(context context.Context, msgIface interface) bool 
          // 具体实现


      func GetList(context context.Context, uid, peerId, sinceMsgId, maxMsgId int64, count int) (interfacebool)]
          // 具体实现


    1. model 和 具体实现方定义并实现 ok 后,那么就还需要一个 service 来统筹管理

      package service

      // 定义一个msgService struct包含了model里面的UserModel和MsgModel两个model
      type msgService struct 
       userModel  model.UserModel
       msgModel   model.MsgModel


      // 定义一个MsgService的变量,并初始化,这样通过MsgService,就能引用并访问model的所有方法
      var (
          MsgService = msgService
        userModel:      user.UserModelImpl,
        msgModel:       msg.MsgModelImpl,
       
      )
    2. 调用访问

      import service
      service.MsgService.Persist(ctx, xxx)

    总结一下,model 对应 MVC 的 M,service 对应 MVC 的 C, 调用访问的地方对应 MVC 的 V

    5 Golang 单例模式

    单例模式是一种常用的软件设计模式,在它的核心结构中只包含一个被称为单例的特殊类,通过单例模式可以保证系统中一个类有且仅有一个实例且该实例可以被外界访问。

    在 Golang 中有一种非常优雅的姿势可以实现,就是通过 sync.Once 来实现,这个也是我在实际项目中所应用的,示例如下:

    import  "github.com/dropbox/godropbox/singleton"
    var SingleService = singleton.NewSingleton(func() (interface, error) 
     return &singleMsgProxy
      MsgModel: msg.MsgModelImpl,
     , nil
    )

    singleton.NewSingleton 就是具体单例模式的实现,然后赋值给 SingleService,这样,在程序中任何需要获取这个对象的时候,就直接通过 SingleService 来调用,这个调用,系统会保证,里面的 singleMsgProxy 只会被初始化对象一次,这个 singleMsgProxy 就是 new 了一个对象,并且这个对象是只需要被初始化一次的。

    6 Golang layout

    Golang 工程 Layout 规范,网上有较多探讨,每个人的理解也会不一致,但是有些基础的理解是可以保持统一的:

  • cmd

  • main 函数文件目录,这个目录下面,每个文件在编译之后都会生成一个可执行的文件。如果只有一个 app 文件,那就是 main.go。这里面的代码尽可能简单。
  • conf

  • 配置文件,如 toml、yaml 等文件
  • config

  • 配置文件的解析
  • docs

  • 文档
  • pkg

  • 底层各种实现,每一种实现封装一个文件夹
  • 业界知名开源项目如 Kubernetes、Istio 都是这样的姿势
  • build

  • 编译脚本
  • CI 脚本
  • 上下线脚本
  • vendor

  • 依赖库
  • 一个简单示例如下:

    $ tree  -d  -L 2
    ├── build
    ├── cmd
    │   ├── apply
    │   └── check
    ├── conf
    ├── config
    ├── docs
    ├── pkg
    │   ├── apply
    │   ├── check
    │   ├── files
    │   ├── k8s
    │   └── options
    └── vendor

    7 cmd & command & flag

    大家看 Kubernetes 的源码就可以发现,会有这么一个现象,Kubernetes 中会有很多二进制程序,然后每个程序,可能会有不同的指令,然后每个指令都会有很多命令行参数。如果大家对 Kubernetes 有一定了解,那么就知道 kubectl 会有如下命令:

    kubectl apply  -f  进行部署
    kubectl delete -f  删除部署
    kubectl get pod    获取 Pod

    那么 kubectl 这个二进制程序,如何能够优雅的支持不同的参数呢?

    下面,还是以我实际项目工程中的应用为例,来进行演示。效果如下,程序 example 包含两个命令 apply 和 check,还有一个 help 命令:

    $ ./example
    Usage:
      example[command]

    Available Commands:
      apply       apply request by json file
      check       check request validity by json file
      help        Help about any command

    Flags:
          --config string   config file[/.xx.yaml] (default "none")
      -h, --help            help for example
          --mode string     mode[cpu  or all] (default "cpu")

    Use "example[command] --help" for more information about a command.

    代码示例如下:

    main 入口

    package main

    import (
     log "github.com/sirupsen/logrus"
     "github.com/spf13/cobra"
     "github.com/spf13/pflag"
     "os"

     "example/cmd/apply"
     "example/cmd/check"
     "example/config"
    )

    func main() 
     var cmdCheck = check.NewVPARequestCheck()
     var cmdApply = apply.NewVPARequestApply()

     var rootCmd = &cobra.CommandUse: "example"

     flags := rootCmd.PersistentFlags()
     addFlags(flags)

     rootCmd.AddCommand(cmdApply, cmdCheck)
     if err := rootCmd.Execute(); err != nil 
      panic(err)
     


    func addFlags(flags *pflag.FlagSet) 
     flags.StringVar(&config.Cfg.KubeConfig, "config""none""config file[/.xx.yaml]")
     flags.StringVar(&config.Cfg.Mode, "mode""cpu""mode[cpu  or all]")

    check 命令实现如下,具体 check 相关的 Run 方法忽略:

    package check

    import (
     "fmt"
     log "github.com/sirupsen/logrus"
     "github.com/spf13/cobra"
     "example/config"
     "example/pkg/check"
     "example/pkg/files"
    )

    type RequestCheckOptions struct 
     configPath string


    func NewRequestCheckOptions() *RequestCheckOptions 
     o := &RequestCheckOptions

     return o


    func NewVPARequestCheck() *cobra.Command 
     o := NewRequestCheckOptions()
     cmd := &cobra.Command
      Use:   "check [json file]",
      Short: "check request validity by json file",
      Long:  "check request by new request json file",
      Args:  cobra.MinimumNArgs(1),
      RunE: func(c *cobra.Command, args []string) error 
       if err := o.Run(args); err != nil 
        return err
       
       return nil
      ,
     

     return cmd

    apply 命令如下,具体 apply 相关的 Run 方法忽略:

    package apply

    import (
     "fmt"
     "github.com/spf13/cobra"
     "example/pkg/apply"
     "example/pkg/files"
    )

    type RequestApplyOptions struct 
     configPath string


    func NewRequestApplyOptions() *RequestApplyOptions 
     o := &RequestApplyOptions

     return o


    func NewVPARequestApply() *cobra.Command 
     o := NewRequestApplyOptions()

     cmd := &cobra.Command
      Use:   "apply [json file]",
      Short: "apply request by json file",
      Long:  "apply request by new request json file",
      Args:  cobra.MinimumNArgs(1),
      RunE: func(c *cobra.Command, args []string) error 
       if err := o.Run(args); err != nil 
        return err
       
       return nil
      ,
     

     return cmd

    然后只需要在各自的 Run 方法中实现对应的逻辑即可。


    腾讯程序员视频号最新视频

    Golang 简洁架构实战

    作者:bearluo,腾讯 IEG 运营开发工程师

    文中项目代码位置:https://github.com/devYun/go-clean-architecture

    由于 golang 不像 java 一样有一个统一的编码模式,所以我们和其他团队一样,采用了 Go 面向包的设计和架构分层这篇文章介绍的一些理论,然后再结合以往的项目经验来进行分包:

    ├── cmd/
    │   └── main.go //启动函数
    ├── etc
    │   └── dev_conf.yaml              // 配置文件
    ├── global
    │   └── global.go //全局变量引用,如数据库、kafka等
    ├── internal/
    │       └── service/
    │           └── xxx_service.go //业务逻辑处理类
    │           └── xxx_service_test.go
    │       └── model/
    │           └── xxx_info.go//结构体
    │       └── api/
    │           └── xxx_api.go//路由对应的接口实现
    │       └── router/
    │           └── router.go//路由
    │       └── pkg/
    │           └── datetool//时间工具类
    │           └── jsontool//json 工具类

    其实上面的这个划分只是简单的将功能分了一下包,在项目实践的过程中还是有很多问题。比如:

    • 对于功能实现我是通过 function 的参数传递还是通过结构体的变量传递?

    • 使用一个数据库的全局变量引用传递是否安全?是否存在过度耦合?

    • 在代码实现过程中几乎全部都是依赖于实现,而不是依赖于接口,那么将 MySQL 切换为 MongDB 是不是要修改所有的实现?

    所以现在在我们工作中随着代码越来越多,代码中各种 init,function,struct,全局变量感觉也越来越乱。每个模块不独立,看似按逻辑分了模块,但没有明确的上下层关系,每个模块里可能都存在配置读取,外部服务调用,协议转换等。久而久之服务不同包函数之间的调用慢慢演变成网状结构,数据流的流向和逻辑的梳理变得越来越复杂,很难不看代码调用的情况下搞清楚数据流向。

    不过就像《重构》中所说:先让代码工作起来-如果代码不能工作,就不能产生价值;然后再试图将它变好-通过对代码进行重构,让我们自己和其他人更好地理解代码,并能按照需求不断地修改代码。

    所以我觉得是时候自我改变一下。

    The Clean Architecture

    在简洁架构里面对我们的项目提出了几点要求:

    1. 独立于框架。该架构不依赖于某些功能丰富的软件库的存在。这允许你把这些框架作为工具来使用,而不是把你的系统塞进它们有限的约束中。

    2. 可测试。业务规则可以在没有 UI、数据库、Web 服务器或任何其他外部元素的情况下被测试。

    3. 独立于用户界面。UI 可以很容易地改变,而不用改变系统的其他部分。例如,一个 Web UI 可以被替换成一个控制台 UI,而不改变业务规则。

    4. 独立于数据库。你可以把 Oracle 或 SQL Server 换成 Mongo、BigTable、CouchDB 或其他东西。你的业务规则不受数据库的约束。

    5. 独立于任何外部机构。事实上,你的业务规则根本不知道外部世界的任何情况。

    上图中同心圆代表各种不同领域的软件。一般来说,越深入代表你的软件层次越高。外圆是战术实现机制,内圆的是战略核心策略。对于我们的项目来说,代码依赖应该由外向内,单向单层依赖,这种依赖包含代码名称,或类的函数,变量或任何其他命名软件实体。

    对于简洁架构来说分为了四层:

    • Entities:实体

    • Usecase:表达应用业务规则,对应的是应用层,它封装和实现系统的所有用例;

    • Interface Adapters:这一层的软件基本都是一些适配器,主要用于将用例和实体中的数据转换为外部系统如数据库或 Web 使用的数据;

    • Framework & Driver:最外面一圈通常是由一些框架和工具组成,如数据库 Database, Web 框架等;

    那么对于我的项目来说,也分为了四层:

    • models

    • repo

    • service

    • api

    代码分层

    models

    封装了各种实体类对象,与数据库交互的、与 UI 交互的等等,任何的实体类都应该放在这里。如:

    import "time"
    
    type Article struct 
     ID        int64     `json:"id"`
     Title     string    `json:"title"`
     Content   string    `json:"content"`
     UpdatedAt time.Time `json:"updated_at"`
     CreatedAt time.Time `json:"created_at"`
    

    repo

    这里存放的是数据库操作类,数据库 CRUD 都在这里。需要注意的是,这里不包含任何的业务逻辑代码,很多同学喜欢将业务逻辑也放到这里。

    如果使用 ORM,那么这里放入的 ORM 操作相关的代码;如果使用微服务,那么这里放的是其他服务请求的代码;

    service

    这里是业务逻辑层,所有的业务过程处理代码都应该放在这里。这一层会决定是请求 repo 层的什么代码,是操作数据库还是调用其他服务;所有的业务数据计算也应该放在这里;这里接受的入参应该是 controller 传入的。

    api

    这里是接收外部请求的代码,如:gin 对应的 handler、gRPC、其他 REST API 框架接入层等等。

    面向接口编程

    除了 models 层,层与层之间应该通过接口交互,而不是实现。如果要用 service 调用 repo 层,那么应该调用 repo 的接口。那么修改底层实现的时候我们上层的基类不需要变更,只需要更换一下底层实现即可。

    例如我们想要将所有文章查询出来,那么可以在 repo 提供这样的接口:

    package repo
    
    import (
     "context"
     "my-clean-rchitecture/models"
     "time"
    )
    
    // IArticleRepo represent the article's repository contract
    type IArticleRepo interface 
     Fetch(ctx context.Context, createdDate time.Time, num int) (res []models.Article, err error)
    

    这个接口的实现类就可以根据需求变更,比如说当我们想要 mysql 来作为存储查询,那么只需要提供一个这样的基类:

    type mysqlArticleRepository struct 
     DB *gorm.DB
    
    
    // NewMysqlArticleRepository will create an object that represent the article.Repository interface
    func NewMysqlArticleRepository(DB *gorm.DB) IArticleRepo 
     return &mysqlArticleRepositoryDB
    
    
    func (m *mysqlArticleRepository) Fetch(ctx context.Context, createdDate time.Time,
     num int) (res []models.Article, err error) 
    
     err = m.DB.WithContext(ctx).Model(&models.Article).
      Select("id,title,content, updated_at, created_at").
      Where("created_at > ?", createdDate).Limit(num).Find(&res).Error
     return
    

    如果改天想要换成 MongoDB 来实现我们的存储,那么只需要定义一个结构体实现 IArticleRepo 接口即可。

    那么在 service 层实现的时候就可以按照我们的需求来将对应的 repo 实现注入即可,从而不需要改动 service 层的实现:

    type articleService struct 
     articleRepo repo.IArticleRepo
    
    
    // NewArticleService will create new an articleUsecase object representation of domain.ArticleUsecase interface
    func NewArticleService(a repo.IArticleRepo) IArticleService 
     return &articleService
      articleRepo: a,
     
    
    
    // Fetch
    func (a *articleService) Fetch(ctx context.Context, createdDate time.Time, num int) (res []models.Article, err error) 
     if num == 0 
      num = 10
     
     res, err = a.articleRepo.Fetch(ctx, createdDate, num)
     if err != nil 
      return nil, err
     
     return
    

    依赖注入 DI

    依赖注入,英文名 dependency injection,简称 DI 。DI 以前在 java 工程里面经常遇到,但是在 go 里面很多人都说不需要,但是我觉得在大型软件开发过程中还是有必要的,否则只能通过全局变量或者方法参数来进行传递。

    至于具体什么是 DI,简单来说就是被依赖的模块,在创建模块时,被注入到(即当作参数传入)模块的里面。想要更加深入的了解什么是 DI 这里再推荐一下 Dependency injection Inversion of Control Containers and the Dependency Injection pattern 这两篇文章。

    如果不用 DI 主要有两大不方便的地方,一个是底层类的修改需要修改上层类,在大型软件开发过程中基类是很多的,一条链路改下来动辄要修改几十个文件;另一方面就是就是层与层之间单元测试不太方便。

    因为采用了依赖注入,在初始化的过程中就不可避免的会写大量的 new,比如我们的项目中需要这样:

    package main
    
    import (
     "my-clean-rchitecture/api"
     "my-clean-rchitecture/api/handlers"
     "my-clean-rchitecture/app"
     "my-clean-rchitecture/repo"
     "my-clean-rchitecture/service"
    )
    
    func main() 
     // 初始化db
     db := app.InitDB()
     //初始化 repo
     repository := repo.NewMysqlArticleRepository(db)
     //初始化service
     articleService := service.NewArticleService(repository)
     //初始化api
     handler := handlers.NewArticleHandler(articleService)
     //初始化router
     router := api.NewRouter(handler)
     //初始化gin
     engine := app.NewGinEngine()
     //初始化server
     server := app.NewServer(engine, router)
     //启动
     server.Start()
    

    那么对于这么一段代码,我们有没有办法不用自己写呢?这里我们就可以借助框架的力量来生成我们的注入代码。

    在 go 里面 DI 的工具相对来说没有 java 这么方便,技术框架一般主要有:wire、dig、fx 等。由于 wire 是使用代码生成来进行注入,性能会比较高,并且它是 google 推出的 DI 框架,所以我们这里使用 wire 进行注入。

    wire 的要求很简单,新建一个 wire.go 文件(文件名可以随意),创建我们的初始化函数。比如,我们要创建并初始化一个 server 对象,我们就可以这样:

    //+build wireinject
    
    package main
    
    import (
     "github.com/google/wire"
     "my-clean-rchitecture/api"
     "my-clean-rchitecture/api/handlers"
     "my-clean-rchitecture/app"
     "my-clean-rchitecture/repo"
     "my-clean-rchitecture/service"
    )
    
    func InitServer() *app.Server 
     wire.Build(
      app.InitDB,
      repo.NewMysqlArticleRepository,
      service.NewArticleService,
      handlers.NewArticleHandler,
      api.NewRouter,
      app.NewServer,
      app.NewGinEngine)
     return &app.Server
    

    需要注意的是,第一行的注解:+build wireinject,表示这是一个注入器。

    在函数中,我们调用wire.Build()将创建 Server 所依赖的类型的构造器传进去。写完 wire.go 文件之后执行 wire 命令,就会自动生成一个 wire_gen.go 文件。

    // Code generated by Wire. DO NOT EDIT.
    
    //go:generate go run github.com/google/wire/cmd/wire
    //+build !wireinject
    
    package main
    
    import (
     "my-clean-rchitecture/api"
     "my-clean-rchitecture/api/handlers"
     "my-clean-rchitecture/app"
     "my-clean-rchitecture/repo"
     "my-clean-rchitecture/service"
    )
    
    // Injectors from wire.go:
    
    func InitServer() *app.Server 
     engine := app.NewGinEngine()
     db := app.InitDB()
     iArticleRepo := repo.NewMysqlArticleRepository(db)
     iArticleService := service.NewArticleService(iArticleRepo)
     articleHandler := handlers.NewArticleHandler(iArticleService)
     router := api.NewRouter(articleHandler)
     server := app.NewServer(engine, router)
     return server
    

    可以看到 wire 自动帮我们生成了 InitServer 方法,此方法中依次初始化了所有要初始化的基类。之后在我们的 main 函数中就只需调用这个 InitServer 即可。

    func main() 
     server := InitServer()
     server.Start()
    

    测试

    在上面我们定义好了每一层应该做什么,那么对于每一层我们应该都是可单独测试的,即使另外一层不存在。

    • models 层:这一层就很简单了,由于没有依赖任何其他代码,所以可以直接用 go 的单测框架直接测试即可;

    • repo 层:对于这一层来说,由于我们使用了 mysql 数据库,那么我们需要 mock mysql,这样即使不用连 mysql 也可以正常测试,我这里使用 github.com/DATA-DOG/go-sqlmock 这个库来 mock 我们的数据库;

    • service 层:因为 service 层依赖了 repo 层,因为它们之间是通过接口来关联,所以我这里使用 github.com/golang/mock/gomock 来 mock repo 层;

    • api 层:这一层依赖 service 层,并且它们之间是通过接口来关联,所以这里也可以使用 gomock 来 mock service 层。不过这里稍微麻烦了一点,因为我们接入层用的是 gin,所以还需要在单测的时候模拟发送请求;

    由于我们是通过 github.com/golang/mock/gomock 来进行 mock ,所以需要执行一下代码生成,生成的 mock 代码我们放入到 mock 包中:

    mockgen -destination .\\mock\\repo_mock.go -source .\\repo\\repo.go -package mock
    
    mockgen -destination .\\mock\\service_mock.go -source .\\service\\service.go -package mock

    上面这两个命令会通过接口帮我自动生成 mock 函数。

    repo 层测试

    在项目中,由于我们用了 gorm 来作为我们的 orm 库,所以我们需要使用 github.com/DATA-DOG/go-sqlmock 结合 gorm 来进行 mock:

    func getSqlMock() (mock sqlmock.Sqlmock, gormDB *gorm.DB) 
     //创建sqlmock
     var err error
     var db *sql.DB
     db, mock, err = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
     if err != nil 
      panic(err)
     
     //结合gorm、sqlmock
     gormDB, err = gorm.Open(mysql.New(mysql.Config
      SkipInitializeWithVersion: true,
      Conn:                      db,
     ), &gorm.Config)
     if nil != err 
      log.Fatalf("Init DB with sqlmock failed, err %v", err)
     
     return
    
    
    func Test_mysqlArticleRepository_Fetch(t *testing.T) 
     createAt := time.Now()
     updateAt := time.Now()
     //id,title,content, updated_at, created_at
     var articles = []models.Article
      1, "test1", "content", updateAt, createAt,
      2, "test2", "content2", updateAt, createAt,
     
    
     limit := 2
     mock, db := getSqlMock()
    
     mock.ExpectQuery("SELECT id,title,content, updated_at, created_at FROM `articles` WHERE created_at > ? LIMIT 2").
      WithArgs(createAt).
      WillReturnRows(sqlmock.NewRows([]string"id", "title", "content", "updated_at", "created_at").
       AddRow(articles[0].ID, articles[0].Title, articles[0].Content, articles[0].UpdatedAt, articles[0].CreatedAt).
       AddRow(articles[1].ID, articles[1].Title, articles[1].Content, articles[1].UpdatedAt, articles[1].CreatedAt))
    
     repository := NewMysqlArticleRepository(db)
     result, err := repository.Fetch(context.TODO(), createAt, limit)
    
     assert.Nil(t, err)
     assert.Equal(t, articles, result)
    

    service 层测试

    这里主要就是用我们 gomock 生成的代码来 mock repo 层:

    func Test_articleService_Fetch(t *testing.T) 
     ctl := gomock.NewController(t)
     defer ctl.Finish()
     now := time.Now()
     mockRepo := mock.NewMockIArticleRepo(ctl)
    
     gomock.InOrder(
      mockRepo.EXPECT().Fetch(context.TODO(), now, 10).Return(nil, nil),
     )
    
     service := NewArticleService(mockRepo)
    
     fetch, _ := service.Fetch(context.TODO(), now, 10)
     fmt.Println(fetch)
    

    api 层测试

    对于这一层,我们不仅要 mock service 层,还需要发送 httptest 来模拟请求发送:

    func TestArticleHandler_FetchArticle(t *testing.T) 
    
     ctl := gomock.NewController(t)
     defer ctl.Finish()
     createAt, _ := time.Parse("2006-01-02", "2021-12-26")
     mockService := mock.NewMockIArticleService(ctl)
    
     gomock.InOrder(
      mockService.EXPECT().Fetch(gomock.Any(), createAt, 10).Return(nil, nil),
     )
    
     article := NewArticleHandler(mockService)
    
     gin.SetMode(gin.TestMode)
    
     // Setup your router, just like you did in your main function, and
     // register your routes
     r := gin.Default()
     r.GET("/articles", article.FetchArticle)
    
     req, err := http.NewRequest(http.MethodGet, "/articles?num=10&create_date=2021-12-26", nil)
     if err != nil 
      t.Fatalf("Couldn't create request: %v\\n", err)
     
    
     w := httptest.NewRecorder()
     // Perform the request
     r.ServeHTTP(w, req)
    
     // Check to see if the response was what you expected
     if w.Code != http.StatusOK 
      t.Fatalf("Expected to get status %d but instead got %d\\n", http.StatusOK, w.Code)
     
    

    总结

    以上就是我对 golang 的项目中发现问题的一点点总结与思考,思考的先不管对不对,总归是解决了我们当下的一些问题。不过,项目总归是需要不断重构完善的,所以下次有问题的时候下次再改呗。

    对于我上面的总结和描述感觉有不对的地方,请随时指出来一起讨论。

    项目代码位置:https://github.com/devYun/go-clean-architecture

    Reference

    https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

    https://github.com/bxcodec/go-clean-arch

    https://medium.com/hackernoon/golang-clean-archithecture-efd6d7c43047

    https://farer.org/2021/04/21/go-dependency-injection-wire/

    以上是关于Golang 编程思维和工程实战的主要内容,如果未能解决你的问题,请参考以下文章

    Golang入门到项目实战 | golang接口和类型的关系

    Golang入门到项目实战 | golang接口值类型接收者和指针类型接收者

    Golang入门到项目实战 | golang接口值类型接收者和指针类型接收者

    接口测试入门课 带你进阶接口测试

    Golang入门到项目实战 | golang接口

    Go 面向接口编程实战