分类 Golang 下的文章

你好,我是一笑。

在企业中越早进行公共库的沉淀越有利,在企业开发中中间件的集成是每个团队都需要的比如 MySQL、Redis、Kafka 这些中间件,几乎每个团队都需要使用,我们在开发项目的时候也需要使用这些中间件来构建我们的应用。

在团队使用 Go 语言构建应用的时候,各个项目组可能会存在自己封装中间件的行为,虽然大部分 SDK 功能比较齐全,但是正式由于功能丰富度上的考虑 sdk提供的接口都是比较原子化的,在实际使用的时候需要我们根据业务的使用场景来进行进一步的封装异常更多的细节。

另外一方面每隔中间件都需要考虑断线重连,SQL 调试等功能,这样使用起来更方便,代码也显得更加简洁,降低了学习成本,让经验不是特别丰富的开发成员快速上手。

各个项目组之间也存在数据交互,比如 A 团队丢到 kafka 之后,b 团队来消费数据,如果这个时候两个团队对 kafka 组件的封装不同使用不同的编解码方式,B 项目从 kafka 中消费出来的数据就需要看 A 团队采用的那种编码方式,这样一来了增加了沟通和开发成本。

还有在发送请求时候,每隔团队都使用了自己的默认值,那就可能出现 B 项目组调用 A 项目组的接口时由于超时时间不一致,导致客户端已经超时,但是服务端还在处理的情况,所以学习中间件的封装是在开发过程中是不可或缺的。

在集成中间件时需要考虑那些内容?

在 Go 中我们常用的中间件都有哪些呢?

  • ES
  • MySQL
  • MongoDB
  • Redis
  • Kafka
  • Promethues
  • 协程池

上面这些都是我们项目中经常集成的中间件。

在集成这些中间件之前,我们首先要进行一些思考。

安全第一,防止直接删库删表

数据是整个系统最宝贵的资源,数据安全怎么重视都不过分。

删库删表操作一般都是通过运维工单经过审核后才能执行,通过代码直接删除数据库和数据库表存在巨大的风险,因为通过代码我们无法判断是否为误操作,MySQL 在删除的时候可能会忘记带 where 条件,或者是传错了表名或者员工的负面情绪导致的恶意删除数据,都会给项目的数据带来极大的风险,所以数据库的删除操作要做严格的把控。

如果是一些日志数据我们可以分表存储进行定期的清理,我们一般可以选择 NoSQL,这样我们就可以对存储核心数据的关系型数据库做严格的把控,如果使用的都是 NoSQL,那么我们可以使用隔离原则,将核心数据库和非核心数据库隔离开,对于编码中需要进行删表的操作来使用白名单的方式只允许在白名单中的表进行操作。

我们同时还可以阻止异常操作:

  • mysql 中不带 where 条件的查询,更新和删除
  • ES 查询条件过多或者过深的嵌套查询语句
  • ....

这些异常操作都可能对集群服务造成影响,导致线上事故。

监控上报

集成后的 SDK 作为应用和这些中间件之间的桥梁,便于增加监控埋点,可以汇总统计这些中间件的调用信息,特别是调用失败的信息,能有效帮助我们发现问题。

多客户端支持

在同一个工程中我们可能会使用不同的中间件服务,比如数据库如果使用读写分离,那么我们就会访问不同的数据库,这些中间件初始化的时候我们通常只会初始化一次,我们可以考虑初始化多次来满足访问不同实例。

与原生操作保持一致,减少学习成本

在封装 sdk 的时候我们应该尽量和原生操作保持一致,这样可以减少学习的成本。

对参数的优雅处理

默认参数和自定义参数可以结合起来提供更好的使用体验,由于各个业务方对不同中间件的依赖程度不同,例如搜索业务会依赖于参数的设置,而且他业务可能只需要基本的默认参数就可以了。

func InitSimpleClient(urls [] string, username, password string) error

func InitClient(clientName string, urls []string, username string, password string, version int) error

func InitClientWithOptions(clientName string, urls []string, username string, password string, version int, options ...Option) error

过多的参数,我们可以使用函数式选项模式来进行处理。

单元测试和使用样例

单元测试和使用样例可以让使用者快速入门,掌握使用细节,降低学习和沟通成本。

文档和代码注释详细清晰

详细的代码注释,基本上可以取代文档的地位,开发之前我们先过一遍文档,使用过程中我们有的时候会直接去看代码来快速了解一些函数的功能以及参数的含义,这样回去看文档可能会更高效。

合理的默认值+参数校验

比如 ES bulk 提交参数的设置,mysql 中设置最大的打开连接数。

其他

  • 断线重连
  • 熔断限流
  • 超时控制

在 Go 中的错误处理与其他语言例如 Java,PHP 的错误处理还是有很大差异的,他没有异常的 try catch 机制,而是在函数的返回值中带上 error,让调用者先判断 error 在处理函数的返回值,调用者通过 error 的级别来判断是否触发 painc,这种高度自主化的错误处理模式原本是 go 语言的一大特色,但是在实际开发中代码会出现一堆的错误判断逻辑。

在发生错误的时候也缺少错误的堆栈信息,从而导致问题排查的效率不高,在这一小节,我们来学习如何优雅的处理 Go 的错误。

Error 的本质

Go Error 就是普通的一个接口,普通的值。

type error interface {
    Error() string    
}

我们经常使用 errors.New() 来返回一个 errorString 的指针。

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
    return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
    s string
}

在基础库中大量的自定义 error。

var (
    ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")
    ErrInvalidUnreadRune = errors.New("bufio: invalid use of UnreadRune")
    ErrBufferFull        = errors.New("bufio: buffer full")
    ErrNegativeCount     = errors.New("bufio: negative count")
)

我们将它们成为哨兵 error,是一个自己定义好的包级别的对象,外面的人只要加载了这个包,就直接可以用到这个对象,可以进行==等一系列运算。

我们可以看到,在里面有一个小细节,哨兵error 中,会把所在包打在里面,这样可以明确的知道,我是哪个包出现了错误,在我们写业务代码时,最好也加上这样的处理。

Error 为什么要返回指针

我们来看一下下面的代码:

package main

import (
    "errors"
    "fmt"
)

type errorString string

func (e errorString) Error() string  {
    return string(e)
}

func New(text string) error  {
    return errorString(text)
}

var ErrNamedType = New("EOF")
var ErrStructType = errors.New("EOF")

func main()  {
    if ErrNamedType == New("EOF"){
        fmt.Println("Named Type Error")
    }

    if ErrStructType == errors.New("EOF") {
        fmt.Println("Struct Type Error")
    }
}

执行上面的代码:

Named Type Error

自定义的 error 在进行等值运算的,明明不是一个error,但是却返回了Named Type Error,这是因为在底层使用的是 string 类型,底层两个string ,由于没有去取地址,这样一来,进行的就是等值返回,对于标准库的 error 来说是不返回的,这也是为什么标准库用结构体包一下,并且返回一大个地址。会比较两个指针地址是否是一个。

这样就可以避免一些奇怪的Bug,两个人定义了两个错误,但是文本内容一模一样,结果相等了。

如果一样用结构体包裹一下,但是使用取地址符,那么在进行等值运算的时候,就会首先按照结构体里面的字段是否一样进行判断。

预定义的特定错误

预定义的特定错误,我们叫为 sentinel error,这个名字来源于计算机编程中使用一个特定值来表示不可能进行进一步处理的做法。所以对于 Go,我们使用特定的值来表示错误。

if err == ErrSomething { … }

类似的 io.EOF,更底层的 syscall.ENOENT。

使用 sentinel 值是最不灵活的错误处理策略,因为调用方必须使用 == 将结果与预先声明的值进行比较。当您想要提供更多的上下文时,这就出现了一个问题,因为返回一个不同的错误将破坏相等性检查。

甚至是一些有意义的 fmt.Errorf 携带一些上下文,也会破坏调用者的 == ,调用者将被迫查看 error.Error() 方法的输出,以查看它是否与特定的字符串匹配。

不应该依赖检测 error.Error 的输出,Error 方法存在于 error 接口主要用于方便程序员使用,但不是程序(编写测试可能会依赖这个返回)。这个输出的字符串用于记录日志、输出到 stdout 等。

Sentinel errors 成为你 API 公共部分。

  • 如果您的公共函数或方法返回一个特定值的错误,那么该值必须是公共的,当然要有文档记录,这会增加 API 的表面积。
  • 如果 API 定义了一个返回特定错误的 interface,则该接口的所有实现都将被限制为仅返回该错误,即使它们可以提供更具描述性的错误。

比如 io.Reader。像 io.Copy 这类函数需要 reader 的实现者比如返回 io.EOF 来告诉调用者没有更多数据了,但这又不是错误。

Sentinel errors 在两个包之间创建了依赖。

sentinel errors 最糟糕的问题是它们在两个包之间创建了源代码依赖关系。例如,检查错误是否等于 io.EOF,您的代码必须导入 io 包。这个特定的例子听起来并不那么糟糕,因为它非常常见,但是想象一下,当项目中的许多包导出错误值时,存在耦合,项目中的其他包必须导入这些错误值才能检查特定的错误条件(in the form of an import loop)。

我的建议是避免在编写的代码中使用 sentinel errors。在标准库中有一些使用它们的情况,但这不是一个您应该模仿的模式。

Error types

我们可以将错误信息结构化,自己来实现一个 error。在 go 源码包中就有对 error 的扩展,与错误值相比,错误类型的一大改进是它们能够包装底层错误以提供更多上下文。

//src : /src/os/error.gop 
type PathError struct {
  Op string
  Path string
  Err error
}

func (e *PathError) Error() string {
  return e.Op + " " + e.Path + " " + e.Err.error()
}

虽然提供了更多的错误信息,但是却造成了新的问题,调用者要使用类型断言和类型 switch,就要让自定义的 error 变为 public。这种模型会导致和调用者产生强耦合,从而导致 API 变得脆弱。

结论是尽量避免使用 error types,虽然错误类型比 sentinel errors 更好,因为它们可以捕获关于出错的更多上下文,但是 error types 共享 error values 许多相同的问题。

因此,我的建议是避免错误类型,或者至少避免将它们作为公共 API 的一部分。

如果你尝试学习 Go 或者你正在为自己建立一个 PoC 或一个玩具项目,这个项目布局是没啥必要的。从一些非常简单的事情开始(一个 main.go 文件绰绰有余)。当有更多的人参与这个项目时,你将需要更多的结构,包括需要一个 toolkit 来方便生成项目的模板,尽可能大家统一的工程目录布局。

因为在大型多人协作的项目中如果没有统一的项目目录结构,随着项目不断的迭代,项目工程目录将变得凌乱无序,不易扩展,不可维护。合理设计目录结构的最终目的是为了提高项目的整洁可读,可扩展性和可交流性。

一个大家可以很好理解的架构本身就是开发人员的一种交流语言,本节将为大家介绍 Go 项目的标准目录布局,了解这些目录一方面是为了给自己设计工程目录结构的时候提供一个标准的参考,另外一方面在我们阅读 Go 项目源码的时候也可以很快了解想这些源码目录具体存放的是哪一方面的源代码。

同时 Go 语言特性支持的目录会重点进行讲解,以及在 GO 工程中那些目录是不推荐的。

一个基本的 Go 工程会有三个目录 cmd, internal, pkg基础目录来分层,这并不是 Go 官方团队定义的标准,但这确实是目前 Go 生态中比较常见的布局形式,目前被普遍使用的目录结构是:github.com/golang-standards/project-layout,我们可以在 github 上看到它的目录结构。

我们将代码克隆到本地后使用 tree 命令进行查看目录结构。

➜  WorkSpace tree go-project-layout
go-project-layout
├── LICENSE.md
├── Makefile
├── README.md
├── README_es.md
├── README_fr.md
├── README_ja.md
├── README_ko.md
├── README_ptBR.md
├── README_ro.md
├── README_ru.md
├── README_tr.md
├── README_zh-CN.md
├── README_zh-TW.md
├── README_zh.md
├── api
│   └── README.md
├── assets
│   └── README.md
├── build
│   ├── README.md
│   ├── ci
│   └── package
├── cmd
│   ├── README.md
│   └── _your_app_
├── configs
│   └── README.md
├── deployments
│   └── README.md
├── docs
│   └── README.md
├── examples
│   └── README.md
├── githooks
│   └── README.md
├── go.mod
├── init
│   └── README.md
├── internal
│   ├── README.md
│   ├── app
│   │   └── _your_app_
│   └── pkg
│       └── _your_private_lib_
├── pkg
│   ├── README.md
│   └── _your_public_lib_
├── scripts
│   └── README.md
├── test
│   └── README.md
├── third_party
│   └── README.md
├── tools
│   └── README.md
├── vendor
│   └── README.md
├── web
│   ├── README.md
│   ├── app
│   ├── static
│   └── template
└── website
    └── README.md

一个 Go 项目主要包含前后端代码,构建后部署的工具,测试和文档这几部分,为了方便大家理解,可以将他们进行一下归类。

  • 配置文件: 配置文件我们一般都会将其放在 configs 目录下
  • 前端代码 :/web、/website、/assets

    • web 目录存放前端代码,服务端模板和单页应用
    • website 下存放 github页面或项目站点相关数据
    • assets 存放项目中用的其他资源(Image,CSS,JavaScript等)
  • 后端代码:/internal,/cmd,/vendor,/third_party,/pkg,/api

    • internal,主要是用来分离应用中共享和非共享的内部代码(go 1.4 开始编译的时候会进行强行校验)

      • 限制公开程序实体只能被其父目录下面的包或者子包引用,适合多个服务共存的情况下隔离各个服务。

        • 从运营管理服务相关代码和用户层代码隔离避免误调用。
        • 目录辨识度高,有效杜绝使用者随意导入
    • cmd:程序入口代码,不含业务逻辑,有两个关键性的原则

        1. 不包含业务代码,不能被其他的包导入,负责程序的启动关闭以及配置的初始化等;
        2. cmd 下面的子目录名跟你期望生成的可执行程序的文件名应该是一致的。
    • vendor:包含了工程中所有依赖的第三方源代码,是 go 中最早依赖包的管理方式,目前大多数项目是使用 go mod 的方式来进行管理,如果需要即使用以前的依赖,还要使用 go mod 我们可以通过 go mod vendor 命令来创建 vendor 目录,在 go 1.14 版本以后我们只需要使用 go vendor 命令就可以了。
    • third_party: 其中存放的也是第三方包,不过这些第三方包是我们自己进行了一定的修改,跟 vendor 进行区分,方便更新
    • pkg:与 internal 相对,外部项目可以直接导入,在实际工作中,为了避免重复造轮子,我们会沉淀整个企业使用的公共包作为独立的仓库提供给各个应用程序使用。
    • api:主要用来存储接口定义文件比如 proto 的一些定义文件。
  • 项目工具,构建和部署相关:Makefile,/scripts/, /tools,/build, /deployments, /init

    • Makefile:makefile提供了一些指令,方便我们编译我们的应用程序,通常工程的根目录下都会有这样的文件;
    • scripts:脚本文件,完成构建,安装,分析检查等功能,Makefile 文件中各个指令的具体实现。
    • tools:项目的一些脚本工具,比如根据项目模型生成相关的代码,可以调用 pkg 和 internal 下面的代码。
    • build: 主要存放安装包和持续集成相关文件,比如说 Dockerfile。
    • deployments:存放系统和容器编排部署配置和模板,比如docker-compose。
    • init:应用程序初始化的脚本,比如systemd 和进程管理(比如supervisord)配置
  • 外部测试代码和数据:/test,这里面用于存放外部包的测试代码和数据,比如经过魔改的第三方库,而应用里面的单元测试还是放在你应用相关的 go 文件下面。
  • 文档:/docs,/examples, README.md,CHANGELOG

    • docs:开发文档,用户手册,安装指南,设计文档等
    • examples:存放工程内部和工程中可被外部导入的包的示例代码,方便使用者快速入门,企业内部公共包可以提供使用这些包构建一些内部项目地址
    • README.md:一般用来介绍项目代码,功能和安装指南,文档地址等。
    • CHANGELOG:记录每一次发版的变更内容
  • 不建议的目录:/src,/model,/common,/util

    • src: 在 GOPATH 模式下,代码会被放到 $GOPATH下面的 src 目录下,在导入路径中就会包含两个 src 目录,这样看上去不是很规范。
    • model:不建议将实体或类型定义放到 model 目录里,特别是 MVC中 M 层的表结构定义,在 DDD 的设计思想下我们推荐按照业务领域来划分。
    • utils 和 common :无法看出包的具体功能, 久而久之随着代码的迭代容易变成大杂烩。

0x00 什么是 IoC

IoC (控制反转)并不是一个新鲜的概念,如果之前有过 Java 经验,那么肯定不会对 IoC 陌生,而 PHP 近几年的框架其实也开始出现了大量的容器应用,在这里需要注意 IoC 和 IoC 容器是两个概念,不要混淆

IoC 是编程思想,而 IoC 容器是帮助我们进行 IoC 的工具,我们通过控制反转把对象之间的调用过程交给 IOC 容器来完成,而最终实现业务代码的解耦。

在 Golang 中 IOC 容器的使用时候,我们要根据我们的具体情况还进行分析是否要使用 IOC

  • 如果你的项目对于性能要求比较高,最好就不要使用 IOC 容器了,因为在 Golang 中实现 IoC 要使用到反射,而反射使用的 空间,如果大量使用反射就会对 Golang 的 GC 造成一定的压力。
  • 要是你的项目对于性能要求不高,追求的是业务的封装性和可维护性,那么就可以考虑使用 IoC 容器。

如果你不了解什么是 IoC 也没有关系,下面会带大家简单的了解一下什么是 IOC 容器。

下面来简单解释一下什么是 IoC。

img

假设现在有两个 Service:UserService 和 OrderService。

  • UserService: 用户服务, GetUserInfo 获取用户信息
  • OrderService: 订单服务, GetOrderInfo 获取订单信息。

这两个 Service 中的方法传递的参数也都是用户 id,现在以代码的形式来模拟一下这个场景。

初始目录如下:

.
├── go.mod
├── main.go
└── svc           #service 目录
    ├── OrderService.go
    └── UserService.go

UserService代码如下:

package svc

import "fmt"

type UserService struct {
}

func NewUserService() *UserService {
    return &UserService{}
}

func (this *UserService) GetUserInfo(uid int) {
    fmt.Println("获取用户 id = ", uid, "的详细信息")
}

OrderService

package svc

import "fmt"

type OrderService struct {
}

func NewOrderService() *OrderService {
    return &OrderService{}
}

func (this *OrderService) GetOrderInfo(uid int) {
    fmt.Println("获取用户 id = ", uid, "的订单信息")
}

基础的 Service 已经封装好了,接下来进行调用。

package main

import "study-go/svc"

func main() {
    uid := 123
    
    orderSvc := svc.NewOrderService()
    userSvc := svc.NewUserService()

    //获取用户详细
    userSvc.GetUserInfo(uid)
    orderSvc.GetOrderInfo(uid)
}

如果代码写到这个程度是否就可以结束了,答案是可以的,并不是很 Low,在 Golang 中并不是要写成和 Java 一样,有大量的设计模式和类与类之间的嵌套。

如果这个时候时候你的领导跟你说要将获取订单信息的方法放到 UserService 里面,你应该怎么做?

package svc

import "fmt"

type UserService struct {
}

func NewUserService() *UserService {
    return &UserService{}
}

func (this *UserService) GetUserInfo(uid int) {
    fmt.Println("获取用户 id = ", uid, "的详细信息")
}

func (this *UserService) GetOrderInfo(uid int) {
    NewOrderService().GetOrderInfo(uid)
}

你完全可以这样写,因为获取用户订单信息是和用户相关的,没有必要放在两个 Service 里面,这个时候我们可以视作 UserService 依赖于 OrderService ,而我们处理的方式是主动处理依赖(在方法内初始化了 NewOrderService),也就是将依赖硬编码到我们的代码中,也叫做控制正转

调用的时候就变成了这个样子:

package main

import "study-go/svc"

func main() {
    uid := 123
    userSvc := svc.NewUserService()

    //获取用户详细
    userSvc.GetUserInfo(uid)
    userSvc.GetOrderInfo(uid)
}

而我们要讲的是依赖反转是被动的,也就是说在我们的 Service 代码里面不会有任何主动初始化 OrderService 的代码,我们需要将我们代码更改一下:

package svc

import "fmt"

type UserService struct {
    orderSvc *OrderService
}

func NewUserService(order *OrderService) *UserService {
    return &UserService{orderSvc: order}
}

func (this *UserService) GetUserInfo(uid int) {
    fmt.Println("获取用户 id = ", uid, "的详细信息")
}

func (this *UserService) GetOrderInfo(uid int) {
    this.orderSvc.GetOrderInfo(uid)
}

观察上方代码的变化:

  1. 我们首先在 UserService 结构体定时以的时候定义了一个新的属性 orderSvc;
  2. 改造了构造方法,在初始化 UserService 的时候,需要给 UserService 传递一个 OrderService 进来;
  3. 在 GetOrderInfo 方法中,因为在构造 UserService 的时候已经传递来了一个 OrderService ,所以可以通过属性的方法进行调用 orderSvc.GetOrderInfo;

那么在调用的时候就变成了这个样子:

package main

import "study-go/svc"

func main() {
    uid := 123
    
    userSvc := svc.NewUserService(svc.NewOrderService())

    //获取用户详细
    userSvc.GetUserInfo(uid)
    userSvc.GetOrderInfo(uid)
}

这个时候,UserService 就被动的 接收了依赖,这就叫做控制反转,也就是 IOC。

这个时候其实是有一些问题的,如果 UserService 有十七八个依赖,我们要怎么样进行处理?这个时候就需要一个工具来帮助我们统一进行管理那就是 IOC 容器,这一部分内容将在下一小节进行讲解。

0x01 利用 Map 存储 Service

在上一小节讲过 IoC 容器是用来帮助我们处理 IoC 的工具,所以我们需要保存两个 Service 之间的依赖关系。

在编写容器代码前,我们现将 OrderService 改造一下,在其中加上 version。

type OrderService struct {
    version string
}

func NewOrderService() *OrderService {
    return &OrderService{version: "1"}
}

我们通过 version 来判断后面我们放到容器中的 OrderService 和取出来的是否一致。

我们先来实现一个基础版本的容器,使用 Map 来存储依赖关系:

package injector

import "reflect"

type BeanMapper map[reflect.Type]reflect.Value

func (this BeanMapper) add(bean interface{}) {
    t := reflect.TypeOf(bean)
    if t.Kind() != reflect.Ptr {
        panic("require ptr object")
    }
    this[t] = reflect.ValueOf(bean)
}

在 Map 中 key 是 reflect.Type, 而 value 是 reflect.Value。

我们来看 add 方法,其实就是将依赖关系存储到 map 中,在使用的时候再从 map 中取出来,在这里为了简化操作,我们目前值允许指针被加入到 map 中。

package injector

var BeanFactory *BeanFactoryImpl

func init() {
    BeanFactory = NewBeanFactory()
}

type BeanFactoryImpl struct {
    BeanMapper BeanMapper
}

func (this *BeanFactoryImpl) Set(vList ...interface{}) {
    if vList == nil || len(vList) == 0 {
        return
    }
    for _, v := range vList {
        this.BeanMapper.add(v)
    }
}

func NewBeanFactory() *BeanFactoryImpl {
    return &BeanFactoryImpl{BeanMapper: make(BeanMapper)}
}

现在创建和添加已经写好了,在这里,我们使用工厂模式来创建容器,init 函数是初始化函数,他在 main 函数执行前执行,用来初始化资源或者提前创建对象。

现在我们再给容器添加获取代码,首先给 mapper 添加 get 方法,很简单,其实就是依靠 map 的获取操作。

func (this BeanMapper) get(bean interface{}) reflect.Value {
    t := reflect.TypeOf(bean)

    if v, ok := this[t]; ok {
        return v
    }
    return reflect.Value{}
}

然后再添加 Factory 的获取方法。

func (this *BeanFactoryImpl) Get(v interface{}) interface{} {
    if v == nil {
        return nil
    }
    bean := this.BeanMapper.get(v)
    if bean.IsValid() {
        return bean.Interface()
    }
    return nil
}

这样一来我们就完成了容器的添加依赖和获取依赖,我们来实践一下。

package main

import (
    "fmt"
    "study-go/injector"
    "study-go/svc"
)

func main() {
    injector.BeanFactory.Set(svc.NewOrderService())
    orderSvc := injector.BeanFactory.Get((*svc.OrderService)(nil))
    fmt.Println(orderSvc)
}

执行一下:

&{2}

输出的时候将 OrderService 中的 version 给打印了出来,这证明我们可以从容器中取到放进去的值。

0x02 处理依赖注入

我们的 UserService 依赖了 OrderService,我们之前使用手动编码来处理依赖关系,现在我们有了容器,现在我们来使用容器来自动注入。

做法其实有很多方案,在Java 可以使用注解, PHP 中使用注释来模拟注解,而在 Golang 中的处理方式稍有不同,因为在 Golang 中并没有对注解的原生支持,所以我们需要使用 Tag 来实现,Tag 是用于标识结构体字段的额外信息,标准库 reflect 包提供了操作 Tag 的方法[1]

package svc

import "fmt"

type UserService struct {
    OrderSvc *OrderService `inject:"-"`
}

func NewUserService(order *OrderService) *UserService {
    return &UserService{OrderSvc: order}
}

func (this *UserService) GetUserInfo(uid int) {
    fmt.Println("获取用户 id = ", uid, "的详细信息")
}

func (this *UserService) GetOrderInfo(uid int) {
    this.OrderSvc.GetOrderInfo(uid)
}

首先我们将 Uservice 中的 orderSvc 改为公开的属性,然后在其后面增加 inject tag,其中 "-" 其实写不写都无所谓,但是因为我们在业务开发的时候,可能还会有 json tag ,所以我们在这里约定 inject:"-" 就等于我要使用依赖注入。

接下来,我们在 BeanFactory 中实现 Apply 方法,用来处理依赖注入。

func (this *BeanFactoryImpl) Apply(bean interface{}) {

    if bean == nil {
        return
    }

    v := reflect.ValueOf(bean)

    if v.Kind() == reflect.Ptr {
        v = v.Elem()
    }

    if v.Kind() != reflect.Struct {
        return
    }

    for i := 0; i < v.NumField(); i++ {
        field := v.Type().Field(i)
        if v.Field(i).CanSet() && field.Tag.Get("inject") != "" {
            if getV := this.Get(field.Type); getV != nil {
                v.Field(i).Set(reflect.ValueOf(getV))
            }
        }
    }
}

首先在代码中判断了是否为结构体,并且从 map 中获取已经实例化好的 Service,在这里我们还需要改造一下 Map 的 get 方法,因为这个时候传递的 field.Type 在 get 方法中会报错,我们需要兼容一下这个类型,在原有代码中增加一下断言。

func (this BeanMapper) get(bean interface{}) reflect.Value {
    var t reflect.Type
    //断言一下 bean
    if bt, ok := bean.(reflect.Type); ok {
        t = bt
    } else {
        t = reflect.TypeOf(bean)
    }

    if v, ok := this[t]; ok {
        return v
    }
    return reflect.Value{}
}

现在让我们来试一下新的成果。

func main() {
    injector.BeanFactory.Set(svc.NewOrderService())

    userService := svc.NewUserService()
    injector.BeanFactory.Apply(userService)
    
    fmt.Println(userService.OrderSvc)
}

现在我们的依赖管理采用 Apply 方法进行管理,Apply 方法自动在容器中寻找其依赖项,然后注入到 UserService 中,运行程序显示结果如下:

&{2}

0x03 动态注入 & Tag 支持表达式

在这个阶段,我们要是实现一个能够基于 Tag 表达式能够动态注入的功能,因为如果项目不复杂,只有几个 Service,那么我们还可以进行手工的将 Service 添加到容器中,那么如果现在随着业务的增加,Service 的数量增加到 20 个,甚至 100 个,我们就需要在 BeanFactory.Set() 方法的参数中写大量的代码来完成容器的初始化。

这么做很显然是不够好的,以为一个完整的容器是需要能够动态注入的,我们只需要基于简单的配置,就可以完成动态注入。

首先我们先创建一个新的文件 ServiceConfig.go 我们将在 ServiceConfig Struct 中来管理我们依赖的创建。

package config

import "study-go/svc"

type ServiceConfig struct {
}

func NewServiceConfig() *ServiceConfig {
    return &ServiceConfig{}
}

func (this *ServiceConfig) OrderService() *svc.OrderService {
    return svc.NewOrderService()
}

以后业务中所有依赖实例化我们都存放在这个文件中,这种做法其实是类似于 Java Spring 的做法。

上面刚才说了,我们要实现一个能够基于 Tag 动态注入的功能,目前字段 Tag 中给的约定是 "-" ,用来直接去到容器中被实例化好的 Service,我们可以更近一步,将约定在增加一份,也就是所谓的表达式,例如 inject:"ServiceConfig.OrderService()

根据这个表达式可以清晰的看出,该字段依赖于 ServiceConfig.OrderService,那么只需要 BeanFactory 代替我们去把这个值拿到就可以了。

但是目前 BeanFactory 目前只有一个 BeanMap 来存储被实例化好的 struct ,并不支持表达式,所以我们需要先增加一个字段。

type BeanFactoryImpl struct {
    BeanMapper BeanMapper             // 存储依赖
    ExprMap    map[string]interface{} // 新增的字段,利用表达式的方式存储依赖 
}

新增的 ExprMap 中 key 存储的就是我们需要支持的表达式,而他的 value 其实就是我们的实例化好的 struct。

接下来,我们先引入一个新的库 github.com/shenyisyn/goft-expr ,这个库的作用是使用表达式来执行函数或者 struct,我们来修改 BeanFactory 中 的 Apply 方法,以支持新的表达式。

func (this *BeanFactoryImpl) Apply(bean interface{}) {
    if bean == nil {
        return
    }

    v := reflect.ValueOf(bean)

    if v.Kind() == reflect.Ptr {
        v = v.Elem()
    }

    if v.Kind() != reflect.Struct {
        return
    }

    for i := 0; i < v.NumField(); i++ {
        field := v.Type().Field(i)
        if v.Field(i).CanSet() && field.Tag.Get("inject") != "" {

            // 兼容老的方式 注入
            if field.Tag.Get("inject") == "-" {
                if getV := this.Get(field.Type); getV != nil {
                    v.Field(i).Set(reflect.ValueOf(getV))
                }
            } else {
                fmt.Println("使用了表达式")
                ret := expr.BeanExpr(field.Tag.Get("inject"), this.ExprMap)
                if ret != nil && !ret.IsEmpty() {
                    v.Field(i).Set(reflect.ValueOf(ret[0]))
                }
            }

        }
    }
}

我们来看这一段代码:

// 兼容老的方式 注入
if field.Tag.Get("inject") == "-" {
    ...
} else {
        ret := expr.BeanExpr(field.Tag.Get("inject"), this.ExprMap)
        if ret != nil && !ret.IsEmpty() {
        v.Field(i).Set(reflect.ValueOf(ret[0]))
}

首先是判断了 inject 是否等于 - 符号,这样是为了兼容老的表达式,也就是默认去读取依赖的 Service,然后在下方,我们去根据表达式取出了已经在容器中准备好的 Service。

这一切都已经准备好了,我们来先试验一下根据表达式来进行注入,先修改 UserService:

type UserService struct {
    OrderSvc *OrderService `inject:"ServiceConfig.OrderService()"`
}

然后是 main.go

func main() {
  srvConfig := config.NewServiceConfig()
  
    injector.BeanFactory.ExprMap = map[string]interface{}{
        "ServiceConfig": srvConfig,
    }
  
    injector.BeanFactory.Set(srvConfig)

    userService := svc.NewUserService()
    injector.BeanFactory.Apply(userService)

    fmt.Println(userService.OrderSvc)
}

接下来执行结果如下:

➜  study-go go run main.go 
&{2}

接下来,我们继续优化我们的代码,能够让代码真正的动态注入,在上面,我们需要手动配置 ExprMap ,但是其实我们同样可以使用反射,来自动完成这一配置,这一次我们先来看 main.go

func main() {
  srvConfig := config.NewServiceConfig()
  
    injector.BeanFactory.Set(srvConfig)
  
    userService := svc.NewUserService()
    injector.BeanFactory.Apply(userService)

    fmt.Println(userService.OrderSvc)
}

BeanFactory 新增 Config 方法:

func (this *BeanFactoryImpl) Config(cfgs ...interface{}) {
    for _, cfg := range cfgs {
        t := reflect.TypeOf(cfg)
        if t.Kind() != reflect.Pointer {
            panic("required ptr object!")
        }
        this.Set(cfg) //把 cfg 本身也加入到 bean 中
        t = t.Elem()
        this.ExprMap[t.Name()] = cfg  // 自动设置 ExprMap 
        v := reflect.ValueOf(cfg).Elem()
        for i := 0; i < t.NumMethod(); i++ {
            method := v.Method(i)
            callRet := method.Call(nil)
            if callRet != nil && len(callRet) == 1 {
                this.Set(callRet[0].Interface())
            }
        }
    }
}

这段代码其实很好理解:

  1. 判断是否为指针,因为这里我们需要的是被实例化好的 Strcut
  2. 然后将 ServiceConfig 也放入到 BeanMap 当中
  3. 然后设置 ExprMap
  4. 最后序列化 ServiceConfig 中的所有 NewXXX 函数

这样一来动态注入就已经完成了

0x04 支持接口注入

如果要让我们的容器支持接口,其实也非常简单,只需要修改 BeanMap 中的 Get 方法即可:

func (this BeanMapper) get(bean interface{}) reflect.Value {
  ...
    if v, ok := this[t]; ok {
        return v
    }
    //如果没有找到那就去看看接口
    for k, v := range this {
        if k.Implements(t) {
            return v
        }
    }
    return reflect.Value{}
}

0x05 处理循环依赖

当我们的 Service 存在多级嵌套的时候,按照现在的代码逻辑是无法去自动完成下一级的自动注入的,我们仅需要在apply 完成的时候,在对其依赖进行递归调用就可以

// 兼容老的方式 注入
if field.Tag.Get("inject") == "-" {
    if getV := this.Get(field.Type); getV != nil {
          v.Field(i).Set(reflect.ValueOf(getV))
          this.Apply(getV)
    }
} else {
        ret := expr.BeanExpr(field.Tag.Get("inject"), this.ExprMap)
        if ret != nil && !ret.IsEmpty() {
             retV := ret[0]
             v.Field(i).Set(reflect.ValueOf(retV))
             this.Apply(retV)
        }
}

0x06 IOC 容器还需要做的事情

到目前为止,我们的 基础版的 IOC 就已经完成了,但是目前还有两个问题没有解决:

  1. 单例和多例,目前这个 IOC 容器,每一次在执行的时候其实都是会调用 NewXXX 实例化出来一个依赖,所以需要能够支持单例或者多例
  2. ServiceConfig 目前还不支持参数实例化

后面如果有时间的话,我会将这块补上。

0x07 结尾

在这里首先要感谢程序员在囧途的沈逸的课程,这篇文章其实就是以 Golang 手撸 IOC 容器为基础编写的,大部分都是他讲课的内容,我在其基础上进行了一些编辑,并且引用了其他的一些文章内容来充实整篇文章,目前与课程不同的是没有去实现单例和多例。

Ref

在导入数据之前,我这里准备了一个book 表,在表中存储了一万条数据。

select * from books limit 1 \G;
*************************** 1. row ***************************
book_id: 220
book_name:  Java项目实战教程+Java程序设计与项目实训教程
book_intr:
book_price1: 72.00
book_price2: 72.50
book_author: 姜华刘闯
book_press: 清华大学出版社
book_date:  2012-09-01
book_kind_str: PHP
book_kind: 2
dd_id: 1328395676
1 row in set (0.001 sec)

我们现在想办法,将这些数据导入到 es 中,在这里我们是用 grom 来完成数据的读取,我们先导入 50 条数据。导入数据 之前我们应该先创建 mapping。

Untitled

先简单说一下 key 和 keyword 的区别,如果需要全文检索那么就用 text ,如果是要精准匹配那么就使用 keyword。

在上一节中我们说过id 这个属性,一般来说我们可以灌入自己的id,不适用 es 自动生成,要不然取数据的时候会比较麻烦。

插入数据的步骤

利用 grom 获取数据

定义db 工具类

package db

import (
    "github.com/jinzhu/gorm"
    _ "github.com/jinzhu/gorm/dialects/mysql"
    "log"
)

var db *gorm.DB

func init() {
    var err error
    db, err = gorm.Open("mysql",
        "root:123123@tcp(localhost:3306)/books?charset=utf8mb4&parseTime=True&loc=Local")
    if err != nil {
        log.Fatal(err)
    }
    db.SingularTable(true)
    db.DB().SetMaxIdleConns(5)
    db.DB().SetMaxOpenConns(10)

}
func  GetDB() *gorm.DB {
    return db
}

定义 model

package model

type Books struct {
    BookID     int     `gorm:"column:book_id;AUTO_INCREMENT;PRIMARY_KEY"`
    BookName   string  `gorm:"column:book_name;type:varchar(50)"`
    BookIntr   string  `gorm:"column:book_intr;type:text"`
    BookPrice1 float64 `gorm:"column:book_price1;type:decimal"`
    BookPrice2 float64 `gorm:"column:book_price2;type:decimal"`
    BookAuthor string  `gorm:"column:book_author;type:varchar(50)"`
    BookPress  string  `gorm:"column:book_press;type:varchar(50)"`
    BookDate   string  `gorm:"column:book_date;type:varchar(50)"`
    BookKind   int     `gorm:"column:book_kind;type:int"`
}
type BookList []*Books

获取数据

package main

import (
    "es/db"
    "es/model"
    "fmt"
)

func main() {
    page := 1
    pageSize := 50
    for {
        bookList := model.BookList{}
        dbConn := db.GetDB().Order("book_id desc").Limit(pageSize).Offset((page - 1) * page).Find(&bookList)
        if dbConn.Error != nil || len(bookList) == 0 {
            break
        }
        fmt.Println(bookList[1])
        break
    }
}

# 
# &{19551  C语言程序设计教程(第2版) 导语_点评_推荐词 27.2 32  湘潭大学出版社  8}

在 main 函数中我们用分页的方式获取数据,每一次获取 50 条。

es 插入数据

我们先将 esclient 抽出一个单独的包。

package es

import (
    "github.com/olivere/elastic/v7"
)

func GetEsClient() (*elastic.Client, error) {
    client, err := elastic.NewClient(
        elastic.SetURL("http://192.168.124.16:9200/"),
        elastic.SetSniff(false),
    )
    if err != nil {
        return nil, err
    }
    return client, nil
}

在之前插入我们使用的是写死的 json 字符串,在这里我们使用模型。

package main

import (
    "context"
    "es/db"
    "es/es"
    "es/model"
    "fmt"
    "log"
    "strconv"
)

func main() {
    page := 1
    pageSize := 50
    client, err := es.GetEsClient()
    if err != nil {
        log.Fatal(err)
    }
    for {
        bookList := model.BookList{}
        dbConn := db.GetDB().Order("book_id desc").Limit(pageSize).Offset((page - 1) * page).Find(&bookList)
        if dbConn.Error != nil || len(bookList) == 0 {
            break
        }
        fmt.Println(bookList[1])

        for _, book := range bookList {
            resp, err := client.Index().Index("books").Id(strconv.Itoa(book.BookID)).BodyJson(book).Do(context.Background())
            if err != nil {
                fmt.Println(err)
            } else {
                fmt.Println(resp)
            }
        }

        break
    }
}

接下来我们执行代码,后查看统计:

curl http://192.168.124.16:9200/books/_count
{"count":50,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0}}

我们可以看到在 es 中已经有 50 条数据了。

$ curl http://192.168.124.16:9200/books/_search | python -m json.tool
{
    "_shards": {
        "failed": 0,
        "skipped": 0,
        "successful": 1,
        "total": 1
    },
    "hits": {
        "hits": [
            {
                "_id": "19552",
                "_index": "books",
                "_score": 1.0,
                "_source": {
                    "BookAuthor": "\u848b\u6e05\u660e",
                    "BookDate": " 2008-10-01",
                    "BookID": 19552,
                    "BookIntr": "\u5bfc\u8bed_\u70b9\u8bc4_\u63a8\u8350\u8bcd",
                    "BookKind": 8,
                    "BookName": " C\u8bed\u8a00\u7a0b\u5e8f\u8bbe\u8ba1\u5b9e\u9a8c\u6307\u5bfc\u4e0e\u4e60\u9898\u89e3\u7b54",
                    "BookPress": "\u4eba\u6c11\u90ae\u7535\u51fa\u7248\u793e",
                    "BookPrice1": 19,
                    "BookPrice2": 19
                },
                "_type": "_doc"
            },
....
}

/books/_search 如果没有接参数的话会默认显示 10 条数据。