从0开始,精通Go语言Rest微服务架构和开发

说在前面

现在拿到offer超级难,甚至连面试电话,一个都搞不到。

尼恩的技术社区中(50+),很多小伙伴凭借 “左手云原生+右手大数据”的绝活,拿到了offer,并且是非常优质的offer,据说年终奖都足足18个月

第二个案例就是:前段时间,一个2年小伙伴希望涨薪到18K, 尼恩把GO 语言的项目架构,给他写入了简历,导致他的简历金光闪闪,脱胎换骨,完全可以去拿头条、腾讯等30K的offer, 年薪可以直接多 20W。

第三个案例就是:一个6年小伙伴凭借Java+go双语言云原生架构,年薪60W

从Java高薪岗位和就业岗位来看,云原生、K8S、GO 现在对于 高级工程师/架构师来说,越来越重要。

所以,尼恩从架构师视角出发,基于尼恩 3高架构知识宇宙,写一本《GO学习圣经》

《GO学习圣经》已经完成的内容有:

Go学习圣经:0基础精通GO开发与高并发架构

Go学习圣经:队列削峰+批量写入 超高并发原理和实操

《GO学习圣经》PDF的最终目标

咱们的目标,不仅仅在于 GO 应用编程自由,更在于 GO 架构自由。

另外,前面尼恩的云原生是没有涉及GO的,但是,没有GO的云原生是不完整的。

所以, GO语言、GO架构学习完了之后,咱们再去打个回马枪,完成云原生的第二部分: 《Istio + K8S CRD的架构与开发实操》 , 帮助大家彻底穿透云原生。

文章目录

基于 Gin 框架的 Rest微服务架构与选型

回顾:java+go 多语言 云原生微服务架构

上个月尼恩指导 一个6年小伙伴简历,使用java+go 多语言 云原生微服务架构,

这个架构,非常牛掰, 帮助 6年小伙,收了一个60W年薪优质offer

主要的架构图图如下:

GO BFF 在技术架构上, 使用 RestFull Api服务层 + Dubbo RPC 消费层的双层架构。

RestFull Api服务层 ,对前端提供 Rest 服务

Dubbo RPC 消费层 , 对后端实现 Dubbo 高性能远程调用。

在这个架构中, 用java+go 多语言 云原生微服务架构中的分布式 配置中心 ,是nacos

而go 中的viper框架,没有对 nacos 提供支持 所以,咱们自己实现,对 nacos 的支持

Gin 框架 简介

Gin 是一个基于 Go 语言的 Web 框架,它具有高性能、易学易用、轻量级等特点,被广泛应用于构建 RESTful API 和微服务等场景。

Gin 框架提供了丰富的中间件支持,可以方便地实现请求路由、参数解析、日志记录、错误处理等功能。

Gin 框架的设计灵感来自于 Martini 框架,但相比之下,Gin 框架更快、更稳定、更易用。

Gin 具有类似于 Martini 的 API 风格,并且它使用了著名的开源项目 httprouter 的自定义版本作为路由基础,使得它的性能表现更高更好,相较 Martini 大约提高了 40 倍。

另外 gin 除了快以外,还具备小巧、精美且易用的特性,目前广受 Go 语言开发者的喜爱,是最流行的 HTTP Web 框架

从 Github Star 上来看, gin框架的趋势如下:

Gin 框架的安装和使用

要安装 Gin 框架,你需要先安装 Go 语言环境,并设置好 GOPATH 和 PATH 环境变量。然后可以使用以下命令安装 Gin:

go get -u github.com/gin-gonic/gin

这将从 GitHub 下载 Gin 框架的最新版本并安装到你的 GOPATH 目录下。

在安装完毕后,我们可以看到项目根目录下的 go.mod 文件也会发生相应的改变.

打开 go.mod 文件,查看如下:

module github.com/go-programming-tour-book/blog-service
go 1.14
require (
    github.com/gin-gonic/gin v1.9.0 // indirect
    github.com/go-playground/universal-translator v0.18.1 // indirect
    ...
)

这些正正就是 gin 所相关联的所有模块包.

大家可能会好奇,为什么 github.com/gin-gonic/gin 后面会出现 indirect 标识,

在执行命令go get 时,Go module 会自动整理go.mod 文件,如果有必要会在部分依赖包的后面增加// indirect注释。

一般而言,被添加// indirect 注释的包,肯定是间接依赖的包,

而没有添加// indirect注释的包则是直接依赖的包,什么叫做直接依赖呢 ? 即明确的出现在某个import语句中。

然而,需要着重强调的是:**并不是所有的间接依赖都会出现在 go.mod文件中。**间接依赖出现在go.mod文件的情况,可能符合下面所列场景的一种或多种:

  • 直接依赖未启用 Go module
  • 直接依赖go.mod 文件中缺失部分依赖

回到上面的 go.mod 文件,查看如下:

module github.com/go-programming-tour-book/blog-service
go 1.14
require (
    github.com/gin-gonic/gin v1.9.0 // indirect
    github.com/go-playground/universal-translator v0.18.1 // indirect
    ...
)

它明明是我们直接通过调用 go get 引用的, 为啥加了 // indirect注释 ?

是因为在我们安装时,这个项目模块还没有真正的去使用它所导致的(还没哟在某个import语句中 出现 )。

另外你会注意到,在 go.mod 文件中有类似 go 1.14 这样的标识位,主要与你创建 Go modules 时的 Go 版本有关。

使用 Gin 框架编写 Hello World

在完成前置动作后,在本节我们先将一个 Demo 运行起来,看看一个最简单的 HTTP 服务运行起来是怎么样的,

使用 Gin 框架编写 Web 应用程序非常简单,以下是一个简单的示例:

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

func main() {
    
    
    router := gin.Default()

    router.GET("/", func(c *gin.Context) {
    
    
        c.String(http.StatusOK, "Hello, World!")
    })

    router.Run(":8080")
}

这个应用程序创建了一个简单的 HTTP 服务器,监听本地的 8080 端口,并在访问根路径时返回 “Hello, World!”。

你可以使用 go run 命令运行这个应用程序:

go run main.go

也可以在goland 工具中直接启动 ,具体如下:

然后在浏览器中访问 http://localhost:8080 就可以看到 “Hello, World!” 的响应了。

当然,这只是 Gin 框架的一个简单示例,你可以根据自己的需求编写更复杂的 Web 应用程序。

main.go的执行过程

接下来我们运行 main.go 文件,查看运行结果,如下:

 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /                         --> main.main.func1 (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8080

我们可以看到启动了服务后,输出了许多运行信息,

在这里我们对运行信息做一个初步的概括分析,分为以下四大块:

首先是gin的运行模式:当前为Release 模式。

 - using code:  gin.SetMode(gin.ReleaseMode)

这是 Go 语言中使用 Gin 框架时,设置运行模式为 Release 模式的代码。

在 Release 模式下,Gin 框架会关闭调试信息和堆栈跟踪,以提高性能和安全性。

这个设置通常在应用程序的 main 函数或初始化代码中进行。

并建议若在测试环境时切换为debug模式,gin.SetMode(gin.DebugMode) 切换为debug模式,

func main() {
    
    
	router := gin.Default()
	gin.SetMode(gin.DebugMode)
	router.GET("/", func(c *gin.Context) {
    
    
		c.String(http.StatusOK, "Hello, World!")
	})

	router.Run(":8080")
}

接下来,是请求的路由注册:注册了 GET /ping 的路由,并输出其调用方法的方法名。

[GIN-debug] GET    /    --> main.main.func1 (3 handlers)

接下来,是监听端口信息:本次启动时监听 8080 端口,由于没有设置端口号等信息,因此默认为 8080。

[GIN-debug] Listening and serving HTTP on :8080

Gin Rest微服务的模块设计

在完成了初步的示例演示后,接下来就是进入具体的预备开发阶段,一般在正式进入业务开发前,我们会针对本次需求的迭代内容进行多类的设计和评审,无设计不开发。

但是问题在于,我们目前还缺很多初始化的东西没有做,因此在本章节中,我们主要针对项目目录结构、接口方案、路由注册、数据库等设计进行思考和设计开发。

Gin Rest微服务目录结构

我们先将项目的标准目录结构创建起来,便于后续的开发,最终目录结构如下:

gin-rest
├── configs
├── docs
├── global
├── internal
│   ├── dao
│   ├── middleware
│   ├── model
│   ├── routers
│   └── service
├── pkg
├── storage
├── scripts
└── third_party
  • configs:配置文件。
  • docs:文档集合。
  • global:全局变量。
  • internal:内部模块。
    • dao:数据访问层(Database Access Object),所有与数据相关的操作都会在 dao 层进行,例如 MySQL、ElasticSearch 等。
    • middleware:HTTP 中间件。
    • model:模型层,用于存放 model 对象。
    • routers:路由相关逻辑处理。
    • service:项目核心业务逻辑。
  • pkg:项目相关的模块包。
  • storage:项目生成的临时文件。
  • scripts:各类构建,安装,分析等操作的脚本。
  • third_party:第三方的资源工具。

Gin框架WEB开发入门

基本安装

1.首先需要安装Go(需要1.10+版本),然后可以使用下面的Go命令安装Gin。

go get -u github.com/gin-gonic/gin

2.将其导入您的代码中:

import “github.com/gin-gonic/gin”

Rest请求路由的配置和使用

路由的本质是前缀树,利用前缀树来实现路由的功能。

Rest请求路径的设置

Gin框架的Rest路由使用非常简单,可以通过定义路由以及处理该路由对应的Handler来接收用户的Web请求。

以下是一个使用Gin框架的路由示例:

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

func main() {
    
    
    router := gin.Default()

    router.GET("/", func(c *gin.Context) {
    
    
        c.String(http.StatusOK, "Hello, World!")
    })

    router.Run(":8080")
}

gin.Default() 表示:实例化一个GIN对象

router.Run(":8080") 表示:启动一个HTTP服务进程,默认监听在8080端口

router.GET(...) 是路由设置。

router路由的路径设置,遵循Restful风格(采用URL定位,HTTP描述操作), 下面是路由配置的几个例子:

//定义handler方法, 类似于java中的 Controller
func getting(c *gin.Context) {
    
    
	c.JSON(http.StatusOK,gin.H{
    
    
		"message":"getting",
	})
}

//定义handler方法, 类似于java中的 Controller
func posting(c *gin.Context) {
    
    
	c.JSON(http.StatusOK,gin.H{
    
    
		"message":"posting",
	})
}


// 设置路由
router := gin.Default()
// 第一个参数是:路径; 第二个参数是:具体操作 func(c *gin.Context)
router.GET("/Get", getting)
router.POST("/Post", posting)
router.PUT("/Put", putting)
router.DELETE("/Delete", deleting)
// 默认启动的是 8080端口
router.Run()

router.GET方法的源码分析

router的GET方法,参数表中分别是路径和多个handler

// GET is a shortcut for router.Handle("GET", path, handle).
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
    
    
	return group.handle(http.MethodGet, relativePath, handlers)
}

处理器 handlerFunc的具体类型:

// HandlerFunc defines the handler used by gin middleware as return value.
type HandlerFunc func(*Context)

所以,定义处理器的时候,需要按照下面这种格式定义函数:

//定义handler方法, 类似于java中的 Controller
func getting(c *gin.Context) {
    
    
	c.JSON(http.StatusOK,gin.H{
    
    
		"message":"getting",
	})
}

JSON中的key是HTTP的状态码,JSON中的value是一个map。

为啥呢? 因为 gin.H 就是一个map:

// H is a shortcut for map[string]interface{}
type H map[string]any

所以,按照下面的方式定义处理器,也是可以的:

func pong(c *gin.Context) {
    
    
	var m = map[string]string{
    
    
		"message": "pong",
	}
	c.JSON(http.StatusOK, m)
}

Gin配置路由的七个主要方法

Gin配置路由是为了处理HTTP请求。

Gin框架中都有和HTTP请求相互对应的方法来定义路由。而HTTP请求包含不同方法,包括GET,POST,PUT,PATCH,OPTIONS,HEAD,DELETE等七种方法。

下面是Gin一系列的 路由定义案例

router := gin.New()
 
router.GET("/testGet",func(c *gin.Context){
    
    
    //处理逻辑
})
 
router.POST("/testPost",func(c *gin.Context){
    
    
    //处理逻辑
})
 
router.PUT("/testPut",func(c *gin.Context){
    
    
    //处理逻辑
})
 
router.DELETE("/testDelete",func(c *gin.Context){
    
    
    //处理逻辑
})
 
router.PATCH("/testPatch",func(c *gin.Context){
    
    
    //处理逻辑
})
 
router.OPTIONS("/testOptions",func(c *gin.Context){
    
    
    //处理逻辑
})
 
router.OPTIONS("/testHead",func(c *gin.Context){
    
    
    //处理逻辑
})

路由分组

Group是一个路由分组器,可以将一组路由规则组织在一起,方便管理和维护。

Gin框架的路由分组可以通过Group函数实现。

下面是没有使用Group方法进行路由配置的例子:

func main() {
    
    
	router := gin.Default()
	
	router.GET("/goods/list",goodsList)
	router.POST("/goods/add",createGoods)
	
	_ = router.Run()
}

使用路由分组改写,与上面的代码是同样的效果:

func main() {
    
    
	router := gin.Default()
 
	goodsGroup := router.Group("/goods")
	goodsGroup.GET("/list", goodsList)
	goodsGroup.GET("/add", createGoods)
 
	_ = router.Run()
}

很多的业务代码,会定义url的不同版本,在这种场景下,就可以使用版本号来分组:

// 两个路由组,都可以访问,大括号是为了保证规范
v1 := r.Group("/v1")
{
    
    
    // 通过 localhost:8080/v1/hello访问,以此类推
    v1.GET("/hello", sayHello)
    v1.GET("/world", sayWorld)
}
v2 := r.Group("/v2")
{
    
    
    v2.GET("/hello", sayHello)
    v2.GET("/world", sayWorld)
}
r.Run(":8080")

访问的时候,可以通过下面的方式,访问v1版本的地址:

localhost:8080/v1/hello
localhost:8080/v1/world

访问的时候,可以通过下面的方式,访问v2版本的地址:

localhost:8080/v2/hello
localhost:8080/v2/world

大规模路由的多文件配置

当我们的路由变得非常多的时候,那么建议遵循以下步骤:

  1. 建立routers包,将不同模块拆分到多个go文件
  2. 每个go文件提供一个路由配置方法,该方法注册实现一个分组的所有的路由
  3. 之后main方法在调用文件的路由配置方法,实现路由注册

例子:第一个路由分组go文件:

/src/../routers/apiRouter.go

package routers

// 这里是一个路由配置方法 , routers包下某一个router对外开放的方法
func LoadRouter(e *gin.Engine) {
    
    
    e.Group("v1")
    {
    
    
        v1.GET("/post", postHandler)
  		v1.GET("/get", getHandler)
    }
  	...
}

例子:第二个路由分组go文件:

/src/../routers/uaaRouter.go

package routers

// 这里是一个路由配置方法 , 是routers包下某一个router对外开放的方法
func LoadUaaRouter(e *gin.Engine) {
    
    
    e.Group("v1")
    {
    
    
        v1.GET("/post", postHandler)
  		v1.GET("/get", getHandler)
    }
  	...
}

main文件实现:

func main() {
    
    
    r := gin.Default()
    // 调用该方法实现注册
    routers.LoadRouter(r)
    routers.LoadUaaRouter(r) // 代表还有多个
    r.Run()
}

规模如果继续扩大也有更好的处理方式(建议别太大,将服务拆分好):

项目规模更大的时候,我们可以遵循以下步骤:

  • 1.建立routers包,内部划分模块(包),每个包有个router.go文件,负责该模块的路由注册
  • 2.建立setup_router.go文件,并编写一个专用的路由初始化方法
  • 3.main.go中按如下方式写入需要注册的路由,可进行路由的初始化
  1. 建立routers包,内部划分模块(包),每个包有个router.go文件,负责该模块的路由注册
├── routers
│   │
│   ├── say
│   │   ├── sayWorld.go
│   │   └── router.go
│   │
│   ├── hello
│   │   ├── helloWorld.go
│   │   └── router.go
│   │
│   └── setup_router.go
│   
└── main.go
  1. 建立setup_router.go文件,并编写一个专用的路由初始化方法:
type Register func(*gin.Engine)

func Init(routers ...Register) *gin.Engine {
    
    
	// 注册路由
	rs := append([]Register{
    
    }, routers...)

	r := gin.New()
	// 遍历调用方法
	for _, register := range rs {
    
    
		register(r)
	}
	return r
}
  1. main.go中按如下方式写入需要注册的路由,可进行路由的初始化:
func main() {
    
    
    // 设置需要加载的路由配置
    r := routers.Init(
		say.Routers,
		hello.Routers, // 后面还可以有多个
	)
	r.Run(":8080")
}

获取参数

获取路径参数

可以通过匹配的方式,获取路径上的参数

  • : 只能匹配1个
  • * 可以匹配任意个数

方式一,使用 : 只匹配1个参数

例子:

// 此规则能够匹配/user/xxx这种格式,但不能匹配/user/ 或 /user这种格式
router.GET("/user/:name", func(c *gin.Context) {
    
    
    name := c.Param("name")
    c.String(http.StatusOK, "Hello %s", name)
})

这里name会作为参数,例如访问http://localhost:8080/user/aaa,name便等于aaa

方式二,使用 * 可以匹配任意个数

router.GET("/user/:name/*action", func(c *gin.Context) {
    
    
    name := c.Param("name")
    action := c.Param("action")
    message := name + " is " + action
    c.String(http.StatusOK, message)
})

规则:

/user/:name/*action

此规则既能匹配 /user/aaa/ 格式,也能匹配 /user/aaa/other1/other2 这种格式

 localhost:8080/user/aaa/

访问: localhost:8080/user/aaa/other1/

访问: localhost:8080/user/aaa/other1/other2/

注意* 只能在最后用

获取URL参数

  • 获取URL参数可以通过DefaultQuery()或Query()方法获取
  • 若参数不存在,DefaultQuery()返回默认值,Query()返回空串
r.GET("/user", func(c *gin.Context) {
    
    
    //指定默认值
    name := c.DefaultQuery("name", "normal")
    //获取具体值
    age := c.Query("age")
    c.String(http.StatusOK, fmt.Sprintf("hello %s, your age is %s", name, age))
})

获取Post方法

r.POST("/form", func(c *gin.Context) {
    
    
    // 设置默认值
    types := c.DefaultPostForm("type", "post")
    username := c.PostForm("username")
    password := c.PostForm("password")
    // 还可以使用Query实现 Get + Post的结合
    name := c.Query("name")
    c.JSON(200, gin.H{
    
    
        "username": username,
        "password": password,
        "types":    types,
        "name": 	name,
    })
})

URL参数和Post参数的获取案例

下面是 URL参数和Post参数的获取案例

func main() {
    
    
	router := gin.Default()
	router.GET("/welcome", welcome)
	router.POST("/login", login)
	router.POST("/post", getPost)
	_ = router.Run()
}
 
// 获取GET传参
func welcome(c *gin.Context) {
    
    
	firstName := c.DefaultQuery("firstname", "unknown")
	lastName := c.DefaultQuery("lastname", "unknown")
	c.JSON(http.StatusOK, gin.H{
    
    
		"first_name": firstName,
		"last_name":  lastName,
	})
}
 
// 获取POST传参
func login(c *gin.Context) {
    
    
	username := c.DefaultPostForm("username", "test")
	password := c.DefaultPostForm("password", "test")
	c.JSON(http.StatusOK, gin.H{
    
    
		"username": username,
		"password": password,
	})
}
 
// 混合获取参数
func getPost(c *gin.Context) {
    
    
    // 获取GET参数
	id := c.Query("id")
	page := c.DefaultQuery("page", "0")
    // 获取POST参数
	name := c.PostForm("name")
	message := c.DefaultPostForm("message", "")
	c.JSON(http.StatusOK, gin.H{
    
    
		"id":      id,
		"page":    page,
		"name":    name,
		"message": message,
	})
}

文件上传

单个文件获取:

在使用 Gin 框架时,可以使用 c.FormFile() 方法来获取上传文件。这个方法会返回一个 *multipart.FileHeader 对象,它包含了上传文件的信息,比如文件名、文件大小、文件类型等。你可以通过这个对象获取文件的内容,并进行相应的处理。

以下是一个简单的示例代码,演示了如何使用 c.FormFile() 方法来上传文件:

func uploadFile(c *gin.Context) {
    
    
    file, err := c.FormFile("file")
    if err != nil {
    
    
        c.String(http.StatusBadRequest, fmt.Sprintf("上传文件失败: %s", err.Error()))
        return
    }

    // 保存文件到本地
    err = c.SaveUploadedFile(file, file.Filename)
    if err != nil {
    
    
        c.String(http.StatusInternalServerError, fmt.Sprintf("保存文件失败: %s", err.Error()))
        return
    }

    c.String(http.StatusOK, fmt.Sprintf("文件 %s 上传成功", file.Filename))
}

在上面的代码中,我们首先使用 c.FormFile() 方法获取上传文件。如果获取失败,我们会返回一个错误信息。如果获取成功,我们就可以使用 c.SaveUploadedFile() 方法将文件保存到本地。最后,我们返回一个成功上传的信息。

在使用 c.FormFile() 方法时,需要注意的是,参数名应该与 HTML 表单中的文件上传控件的 name 属性相同。在上面的示例中,我们假设上传文件控件的 name 属性为 "file"

上传文件的时候,也可以通过限制大小,参考代码如下:

r := gin.Default()
// 给表单限制上传大小 (默认 32 MiB)
r.MaxMultipartMemory = 8 << 20 // 8 MiB
r.POST("/upload", func(c *gin.Context) {
    
    
    file, err := c.FormFile("file")
    if err != nil {
    
    
        c.String(500, "上传文件出错")
    }

    // 上传到指定路径
    c.SaveUploadedFile(file, "C:/desktop/"+file.Filename)
    c.String(http.StatusOK, "fileName:", file.Filename)
})

多个文件获取(只展示核心部分):

// 获取MultipartForm
form, err := c.MultipartForm()
if err != nil {
    
    
    c.String(http.StatusBadRequest, fmt.Sprintf("get err %s", err.Error()))
}

// 获取所有文件
files := form.File["files"]
for _, file := range files {
    
    
    // 逐个存
    fmt.Println(file.Filename)
}
c.String(200, fmt.Sprintf("upload ok %d files", len(files)))

表单验证

GIN提供了两种方法来进行表单验证: Must Bind / Should Bind。“Must Bind” 和 “Should Bind” 是在编写程序时常用的两个概念。

  • “Must Bind” 意味着必须绑定某个变量或者参数,否则程序将无法正常运行。这种情况通常出现在必需的配置项或者必需的输入参数未被正确设置的情况下。
  • “Should Bind” 则表示建议绑定某个变量或者参数,但是如果未绑定也不会影响程序的正常运行。这种情况通常出现在可选的配置项或者可选的输入参数未被设置的情况下。

简而言之,“Must Bind” 表示必须绑定,否则程序无法正常运行;“Should Bind” 表示建议绑定,但不是必须的。

在 Gin 框架中进行表单验证,可以使用 Gin 提供的 binding 包和 validator 包来实现。有3步骤:

  • 1 导入 bindingvalidator
  • 2 定义表单结构体,并使用 binding:""validate:"" 标记字段
  • 3 在路由处理函数中使用 ShouldBindWith 方法解析请求参数,并使用 validator 包的 ValidateStruct 方法进行验证

具体步骤如下:

  1. 导入 bindingvalidator 包:
import (
    "github.com/gin-gonic/gin"
    "github.com/gin-gonic/gin/binding"
    "gopkg.in/go-playground/validator.v9"
)
  1. 定义表单结构体,并使用 binding:"required"validate:"" 标记字段:
type LoginForm struct {
    
    
    Username string `json:"username" binding:"required" validate:"required"`
    Password string `json:"password" binding:"required" validate:"required"`
}

其中:

  • binding:"required" 表示该字段在请求中必须存在,否则会返回 400 错误;
  • validate:"required" 表示该字段必须有值,否则会返回 422 错误。
  1. 在路由处理函数中使用 ShouldBindWith 方法解析请求参数,并使用 validator 包的 ValidateStruct 方法进行验证:
func Login(c *gin.Context) {
    
    
    var form LoginForm
    if err := c.ShouldBindWith(&form, binding.JSON); err != nil {
    
    
        c.JSON(http.StatusBadRequest, gin.H{
    
    "error": err.Error()})
        return
    }

    validate := validator.New()
    if err := validate.Struct(form); err != nil {
    
    
        c.JSON(http.StatusUnprocessableEntity, gin.H{
    
    "error": err.Error()})
        return
    }

    // TODO: 处理登录逻辑
}

其中,有两个要点:

  • ShouldBindWith 方法会根据请求头中的 Content-Type 自动解析请求参数,第二个参数指定了解析器类型,这里使用了 JSON 解析器;
  • ValidateStruct 方法会根据表单结构体的标记进行验证,如果有错误则返回错误信息。

这样就可以在 Gin 框架中进行表单验证了。

接收表单请求,获取用户名和密码:

type LoginForm struct {
    
    
    Username string `json:"username" binding:"required" validate:"required"`
    Password string `json:"password" binding:"required" validate:"required"`
}

 
func main() {
    
    
	router := gin.Default()
	router.POST("/login", func(c *gin.Context) {
    
    
		var loginForm LoginForm
		if err := c.ShouldBind(&loginForm); err != nil {
    
    
			fmt.Println(err.Error())
			c.JSON(http.StatusBadRequest, gin.H{
    
    
				"error": err.Error(),
			})
			return
		}
		c.JSON(http.StatusOK, gin.H{
    
    
			"msg": "login",
		})
	})
	_ = router.Run()
}

使用POST请求发送JSON数据

http://localhost:8080/login

验证失败

请求信息:

{
    
    
    "password":"123"
}

请求的结果

验证成功

请求信息:

{
    
    
    "username":"David",
    "password":"123"
}

请求的结果

设置校验的规则

上面的案例,设置了 validate:“required” 。 如果required字段没有收到,错误日志会告知:

{
    
    
  "error": "Key: 'LoginForm.Username' Error:Field validation for 'Username' failed on the 'required' tag"
}

除此之外,还可以有很多的校验。比如,通过tag设置范围校验,例如

binding:"required,gt=10"  =》 代表该值需要大于10
time_format:"2006-01-02" time_utc:"1"   =》 时间格式 校验

此外,还允许自定义校验方式

content-type绑定

在 Gin 框架中,可以通过 ShouldBindJSON() 方法将请求体中的 Content-Type 是 application/json的数据与指定的结构体进行绑定。下面是一个使用 绑定请求体的示例:

package main

import (
    "github.com/gin-gonic/gin"
)

type Login struct {
    
    
    Username string `json:"username" binding:"required"`
    Password string `json:"password" binding:"required"`
}

func main() {
    
    
    r := gin.Default()

    r.POST("/login", func(c *gin.Context) {
    
    
        var login Login
        // 将request的body中的数据,按照json格式解析到结构体
        if err := c.ShouldBindJSON(&login); err != nil {
    
    
    // 如果发送的不是json格式,那么输出:  "error": "invalid character '-' in numeric literal"
            c.JSON(400, gin.H{
    
    "error": err.Error()})
            return
        }

        // ...
    })

    r.Run()
}

在上面的示例中,我们定义了一个 Login 结构体,用于存储登录请求中的用户名和密码。

在路由处理函数中,我们使用 ShouldBindJSON() 方法将请求体中的 JSON 数据与 Login 结构体进行绑定。

如果绑定失败,我们将返回一个 400 错误响应,否则我们将继续处理登录逻辑。

需要注意的是,这里使用了 binding:"required" 标签来指定 UsernamePassword 字段必须存在。如果请求体中缺少这些字段,绑定将失败,并返回一个错误响应。

除了使用ShouldBindJSON,也可以使用Bind方法,参考代码如下:

r.POST("/loginJSON", func(c *gin.Context) {
    
    
    // 声明接收的变量
    var login Login

    // 默认绑定form格式
    if err := c.Bind(&login); err != nil {
    
    
        // 根据请求头中content-type自动推断
        c.JSON(http.StatusBadRequest, gin.H{
    
    "error": err.Error()})
        return
    }

    // 输出结果
    c.JSON(http.StatusOK, gin.H{
    
    
        "status":   "200",
        "user":     login.User,
        "password": login.Password,
    })
})

BindShouldBindJSON 都是 Gin 框架中用于将请求体中的数据绑定到结构体中的方法。

它们的区别在于,Bind 方法会根据请求头中的 Content-Type 自动选择绑定方法,而 ShouldBindJSON 方法则只会绑定 JSON 格式的请求体。

举个例子,如果请求头中的 Content-Type 是 application/json,那么 Bind 方法和 ShouldBindJSON 方法都会将请求体中的 JSON 数据绑定到结构体中。但如果 Content-Type 是 application/xml,那么 Bind 方法会选择绑定 XML 格式的请求体,而 ShouldBindJSON 方法则会返回错误,因为它只能绑定 JSON 格式的请求体。

因此,如果你确定请求体中的数据是 JSON 格式,可以直接使用 ShouldBindJSON 方法,否则建议使用 Bind 方法。

在进行绑定时,可以使用 Content-Type 请求头来指定请求体的格式。

Gin 数据返回类型

gin常见的三种响应数据:JSONXMLYAML

// 1.JSON
r.GET("/someJSON", func(c *gin.Context) {
    
    
    c.JSON(200, gin.H{
    
    
        "message": "Json",
        "status":  200,
    })
})
// 2.XML
r.GET("/someXML", func(c *gin.Context) {
    
    
    c.XML(200, gin.H{
    
    "message": "abc"})
})
// 3.YAML
r.GET("/someYAML", func(c *gin.Context) {
    
    
    c.YAML(200, gin.H{
    
    "name": "zhangsan"})
})
// 4.protobuf
r.GET("/someProtoBuf", func(c *gin.Context) {
    
    
    reps := []int64{
    
    1, 2}
    data := &protoexample.Test{
    
    
        Reps:  reps,
    }
    c.ProtoBuf(200, data)
})

Gin 重定向

在 Gin 中,可以使用 c.Redirect() 方法进行重定向。该方法接受两个参数:重定向的目标 URL 和重定向的状态码。例如,以下代码将会将浏览器重定向到 https://www.example.com

func redirectHandler(c *gin.Context) {
    
    
    c.Redirect(http.StatusMovedPermanently, "https://www.example.com")
}

其中,http.StatusMovedPermanently 是一个常量,表示 301 状态码。你也可以使用其他状态码,例如 http.StatusFound(302)。

如果你想要在 URL 中包含查询参数,可以将它们添加到目标 URL 中。例如,以下代码将会将浏览器重定向到 https://www.example.com?foo=bar

func redirectHandler(c *gin.Context) {
    
    
    c.Redirect(http.StatusMovedPermanently, "https://www.example.com?foo=bar")
}

异步执行

在 Gin 中异步执行可以使用 Go 语言的协程(goroutine)来实现。在处理请求的处理函数中,可以使用 go 关键字来启动一个新的协程,使得处理函数可以立即返回,并且新的协程可以在后台继续执行。

举个例子,如果我们需要在处理函数中执行一个比较耗时的操作,可以这样写:

func handleRequest(c *gin.Context) {
    
    
    // 需要搞一个副本
    copyContext := c.Copy()
    
    // 启动一个新的协程来执行耗时操作
    go func() {
    
    
        // 执行耗时操作
        time.Sleep(5 * time.Second)
        // 操作完成后,可以通过 c.Writer 写入响应数据
        copyContext.Writer.WriteString("耗时操作完成")
    }()

}

在这个例子中,我们使用了匿名函数来启动一个新的协程,该协程会执行一个耗时操作,然后在操作完成后通过 c.Writer 写入响应数据。

需要注意的是,在协程中访问 Gin 的上下文对象 c 时,需要使用闭包,以避免竞态条件。此外,还需要注意协程的数量,避免过多的协程导致系统资源耗尽。

会话控制

  • cookie相关
  • session相关
  • token相关

cookie相关

  • 可以通过 GetCookie 方法获取客户端请求中携带的 cookie
  • 可以通过 SetCookie 方法设置 cookie

在 Gin 中,可以通过 SetCookie 方法设置 cookie,例如:

func main() {
    
    
    router := gin.Default()

    router.GET("/set-cookie", func(c *gin.Context) {
    
    
        c.SetCookie("username", "johndoe", 3600, "/", "localhost", false, true)
        c.String(http.StatusOK, "Cookie has been set")
    })

    router.Run(":8080")
}

在上面的例子中,我们使用 SetCookie 方法设置了一个名为 “username” 的 cookie,它的值为 “johndoe”,过期时间为 3600 秒,路径为 “/”,域名为 “localhost”,不启用安全标志,启用 HTTPOnly 标志。在客户端可以通过 document.cookie 属性读取该 cookie。

另外,可以通过 GetCookie 方法获取客户端请求中携带的 cookie,例如:

func main() {
    
    
    router := gin.Default()

    router.GET("/get-cookie", func(c *gin.Context) {
    
    
        username, err := c.Cookie("username")
        if err != nil {
    
    
            c.String(http.StatusBadRequest, "Cookie not found")
        } else {
    
    
            c.String(http.StatusOK, "Hello "+username)
        }
    })

    router.Run(":8080")
}

在上面的例子中,我们使用 Cookie 方法获取客户端请求中名为 “username” 的 cookie 的值,并将其作为字符串拼接到响应中。如果客户端请求中没有携带该 cookie,则返回 “Cookie not found”。

session相关

Session 是一种在客户端和服务器之间保存状态的机制,它可以用来存储用户的登录信息、购物车信息等。Gin 提供了一个中间件 gin-contrib/sessions,它可以帮助我们在 Gin 中使用 Session。

要使用 Gin Session,我们需要先安装 gin-contrib/sessions 包。可以使用以下命令进行安装:

go get github.com/gin-contrib/sessions

安装完成后,我们需要在代码中引入 gin-contrib/sessions 包,并创建一个 Session 存储引擎。Gin 支持多种 Session 存储引擎,包括内存存储、Cookie 存储、Redis 存储等。以下是一个使用 Cookie 存储的示例:

import (
    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/cookie"
    "github.com/gin-gonic/gin"
)

func main() {
    
    
    r := gin.Default()

    // 设置 Session 中间件
    store := cookie.NewStore([]byte("secret"))
    r.Use(sessions.Sessions("mysession", store))

    // 设置路由
    r.GET("/set", func(c *gin.Context) {
    
    
        session := sessions.Default(c)
        session.Set("username", "johndoe")
        session.Save()
        c.JSON(200, gin.H{
    
    "message": "Session saved"})
    })

    r.GET("/get", func(c *gin.Context) {
    
    
        session := sessions.Default(c)
        username := session.Get("username")
        c.JSON(200, gin.H{
    
    "username": username})
    })

    r.Run(":8080")
}

在上面的示例中,我们首先创建了一个 Cookie 存储引擎,并将其作为 Session 中间件添加到 Gin 中。然后,我们定义了两个路由,一个用于设置 Session,另一个用于获取 Session。

在设置 Session 的路由中,我们使用 sessions.Default© 获取当前请求的 Session 对象,并使用 session.Set() 方法设置一个键值对。

在获取 Session 的路由中,我们同样使用 sessions.Default© 获取当前请求的 Session 对象,并使用 session.Get() 方法获取之前设置的键值对。

需要注意的是,Session 中间件需要在路由之前添加,这样才能在路由中使用 Session。

另外,Session 存储引擎中的 secret 参数应该是一个随机字符串,用于加密 Session 数据。

token相关

通常为了分布式和安全性,我们会采取更好的方式,比如使用Token 认证,来实现跨域访问,避免 CSRF 攻击,还能在多个服务间共享。

Token 是一种用于身份验证和授权的机制,通常用于保护 Web 应用程序中的敏感资源。

在 Gin 中,可以使用 JWT(JSON Web Token)作为 Token 机制。

使用 Gin 和 JWT 实现 Token 鉴权的步骤如下:

  1. 在用户登录成功后,生成一个 JWT Token,并将其返回给客户端。
  2. 客户端在每次请求中将 Token 作为请求头发送给服务器。
  3. 服务器在接收到请求后,解析 Token,验证其有效性和正确性。
  4. 如果 Token 有效,则允许用户访问相应的资源;否则,返回错误信息。

在 Gin 中,可以使用第三方库如 jwt-go 来实现 JWT 的生成和解析。具体实现方式可以参考以下代码:

import (
    "github.com/gin-gonic/gin"
    "github.com/dgrijalva/jwt-go"
)

// 生成 JWT Token
func generateToken(userId int64) (string, error) {
    
    
    token := jwt.New(jwt.SigningMethodHS256)
    claims := token.Claims.(jwt.MapClaims)
    claims["userId"] = userId
    tokenString, err := token.SignedString([]byte("secret"))
    if err != nil {
    
    
        return "", err
    }
    return tokenString, nil
}

// 鉴权中间件
func authMiddleware() gin.HandlerFunc {
    
    
    return func(c *gin.Context) {
    
    
        tokenString := c.GetHeader("Authorization")
        if tokenString == "" {
    
    
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
    
    "error": "Unauthorized"})
            return
        }
        token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{
    
    }, error) {
    
    
            if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
    
    
                return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
            }
            return []byte("secret"), nil
        })
        if err != nil {
    
    
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
    
    "error": "Unauthorized"})
            return
        }
        if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
    
    
            userId := int64(claims["userId"].(float64))
            c.Set("userId", userId)
            c.Next()
        } else {
    
    
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
    
    "error": "Unauthorized"})
            return
        }
    }
}

// 示例路由
func main() {
    
    
    r := gin.Default()

    // 登录路由
    r.POST("/login", func(c *gin.Context) {
    
    
        // 模拟登录成功
        userId := int64(123)
        token, err := generateToken(userId)
        if err != nil {
    
    
            c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
    
    "error": "Internal Server Error"})
            return
        }
        c.JSON(http.StatusOK, gin.H{
    
    "token": token})
    })

    // 需要鉴权的路由
    r.GET("/protected", authMiddleware(), func(c *gin.Context) {
    
    
        userId := c.MustGet("userId").(int64)
        c.JSON(http.StatusOK, gin.H{
    
    "userId": userId})
    })

    r.Run(":8080")
}

在上面的示例中,我们定义了一个生成 JWT Token 的函数 generateToken 和一个鉴权中间件 authMiddleware。

在登录成功后,我们将生成的 Token 返回给客户端。

在需要鉴权的路由中,我们使用 authMiddleware 作为中间件来验证 Token 的有效性:

  • 如果 Token 有效,则将用户的 userId 存储到上下文中
  • 否则返回错误信息。

Gin中间件

gin中间件,类似spring mvc 的拦截器 、过滤器。

gin中间件作用就是在处理具体的route请求时,提前做一些业务,还可以在业务执行完后执行一些操作。比如身份校验、日志打印等操作。

中间件分为:全局中间件路由中间件,区别在于前者会作用于所有路由。

其实使用router := gin.Default()定义route时,默认带了Logger()Recovery()

看看 gin.Default() 源码就了解了:

// Default returns an Engine instance with the Logger and Recovery middleware already attached.
func Default() *Engine {
    
    
	debugPrintWARNINGDefault()
	engine := New()
	engine.Use(Logger(), Recovery())
	return engine
}

默认Gin中间件

Gin本身也提供了一些中间件给我们使用:

func BasicAuth(accounts Accounts) HandlerFunc // 身份认证
func BasicAuthForRealm(accounts Accounts, realm string) HandlerFunc
func Bind(val interface{
    
    }) HandlerFunc //拦截请求参数并进行绑定
func ErrorLogger() HandlerFunc       //错误日志处理
func ErrorLoggerT(typ ErrorType) HandlerFunc //自定义类型的错误日志处理
func Logger() HandlerFunc //日志记录
func LoggerWithConfig(conf LoggerConfig) HandlerFunc
func LoggerWithFormatter(f LogFormatter) HandlerFunc
func LoggerWithWriter(out io.Writer, notlogged ...string) HandlerFunc
func Recovery() HandlerFunc
func RecoveryWithWriter(out io.Writer) HandlerFunc
func WrapF(f http.HandlerFunc) HandlerFunc //将http.HandlerFunc包装成中间件
func WrapH(h http.Handler) HandlerFunc //将http.Handler包装成中间件

自定义Gin中间件

自定义中间件的方式很简单,我们只需要实现一个函数,返回gin.HandlerFunc类型的参数即可:

// HandlerFunc 本质就是一个函数,入参为 *gin.Context
// HandlerFunc defines the handler used by gin middleware as return value.
type HandlerFunc func(*Context)

自定义Gin中间件 用于检查token ,示例代码:

func TokenRequired() gin.HandlerFunc {
    
    
	return func(c *gin.Context) {
    
    
		var token string
		for k, v := range c.Request.Header {
    
    
			if k == "x-token" {
    
    
				token = v[0]
			}
			fmt.Println(k, v, token)
		}
		if token != "test" {
    
    
			c.JSON(http.StatusOK, gin.H{
    
    
				"msg": "login failed",
			})
			c.Abort()
		}
		c.Next()
	}
}
 
func main() {
    
    
	router := gin.Default()
	router.Use(TokenRequired())
	router.GET("/ping", func(c *gin.Context) {
    
    
		c.JSON(http.StatusOK, gin.H{
    
    
			"message": "pong",
		})
	})
	_ = router.Run()
}

中间件控制的方法

在 Gin 框架中,中间件的顺序非常重要,因为它们按照添加的顺序依次执行。如果您希望中间件以特定的顺序执行,可以使用 Gin 框架提供的 Use() 方法来添加中间件。例如,如果您希望在日志中记录请求之前先执行身份验证中间件,则应该先添加身份验证中间件,然后再添加日志中间件,如下所示:

router := gin.Default()

// 添加身份验证中间件
router.Use(authMiddleware)

// 添加日志中间件
router.Use(loggerMiddleware)

// 添加路由处理函数
router.GET("/hello", func(c *gin.Context) {
    
    
    c.String(http.StatusOK, "Hello, World!")
})

router.Run(":8080")

在这个例子中,authMiddleware 会在 loggerMiddleware 之前执行,因为它先被添加到 Gin 的中间件处理链中。

另外,gin提供了两个函数Abort()Next(),二者区别在于:

  1. next()函数会跳过当前中间件中next()后的逻辑,当下一个中间件执行完成后再执行剩余的逻辑
  2. abort()函数执行终止当前中间件以后的中间件执行,但是会执行当前中间件的后续逻辑

举例子更好理解:我们注册中间件顺序为m1m2m3,如果采用next() 执行顺序就是

  1. m1的next()前面m2的next()前面m3的next()前面
  2. 业务逻辑
  3. m3的next()后续m2的next()后续m1的next()后续

那如果m2中间调用了Abort(),则m3业务逻辑不会执行,只会执行m2的next()后续m1的next()后续

40岁老架构师尼恩提示:

这个流程,和java的过滤器责任链模式,何其一致

局部中间件

Gin 框架中有两种类型的中间件:

  • 全局中间件
  • 局部中间件。

全局中间件是在 Gin 实例创建时添加的,它们将应用于所有的路由请求。

全局中间件可以通过 Use() 方法添加到 Gin 实例中,例如:

router := gin.Default()
router.Use(Logger())

在上面的代码中,Logger() 函数是一个全局中间件,它将在所有请求之前打印请求的信息。

局部中间件只会应用于某些路由请求。

  • 可以在路由设置的时候,设置局部中间件
  • 可以使用 Group 方法创建一个路由组,然后在这个路由组上设置局部中间件

在路由设置的时候,设置局部中间件:

router := gin.Default()

// 添加全局中间件
router.Use(Logger())

// 通过 Handle()添加局部中间件
router.GET("/posts", Auth(), GetPosts)

在上面的代码中,Auth() 函数是一个局部中间件,它只会在 /posts 路由请求中应用。

在 Gin 中,可以使用 Group 方法创建一个路由组,然后在这个路由组上设置局部中间件。

例如,以下代码创建了一个路由组 /api,并在这个路由组上设置了一个局部中间件 authMiddleware

func authMiddleware(c *gin.Context) {
    
    
    // 检查用户是否已经登录
    // 如果用户已经登录,则继续处理请求
    // 如果用户未登录,则返回 401 Unauthorized 错误
}

func main() {
    
    
    r := gin.Default()

    api := r.Group("/api")
    api.Use(authMiddleware)

    api.GET("/users", func(c *gin.Context) {
    
    
        // 处理 GET /api/users 请求
    })

    api.POST("/users", func(c *gin.Context) {
    
    
        // 处理 POST /api/users 请求
    })

    r.Run()
}

在上面的代码中,authMiddleware 函数是一个中间件函数,用于检查用户是否已经登录。api 是一个路由组,表示所有以 /api 开头的请求都会进入这个路由组。api.Use(authMiddleware) 表示在这个路由组上使用 authMiddleware 中间件。

这样,所有以 /api 开头的请求都会先进入 authMiddleware 中间件函数进行身份验证,如果身份验证通过,则继续处理请求,否则返回 401 Unauthorized 错误。

Gin的路由框架:httprouter框架

Gin比Martini的效率高好多,

究其原因是:因为Gin使用了httprouter这个路由框架, httprouter的git地址是: httprouter源码.

httprouter其实就是使用了一个radix tree(前缀树)来管理请求的URL,

前缀树和基数树(radix tree)的区别:

trie又叫前缀树,是一个多叉树,广泛应用于字符串搜索,每个树节点存储一个字符,从根节点到任意一个叶子结点串起来就是一个字符串;radix tree是优化之后的前缀树,对空间进一步压缩。

下图左侧是字符串 sex,seed,sleep,son 四个字段串的Trie数据结构表示. 可用看到sleep这个字符串需要5个节点表示. 其实e后面只跟一个p, 也就是只有一个子节点, 是完全可以和父节点压缩合并的. 右侧是优化后的数据结构, 节省了空间,同时也提高了查询效率(左边字符串sleep查询需要5步, 右边只需要3步), 这就是radix tree.

gin框架路由原理

httprouter是一个高性能路由分发器,它负责将不同方法的多个路径分别注册到各个handle函数,当收到请求时,负责快速查找请求的路径是否有相对应的处理函数,并且进行下一步业务逻辑处理。golang的gin框架采用了httprouter进行路由匹配,httprouter 是通过radix tree来进行高效的路径查找;同时路径还支持两种通配符匹配。

httprouter会对每种http方法(post、get等)都会生成一棵基数树,其中树节点node结构如下:

type nodestruct {
    
    
    path    string     //该节点对应的path
    indices   string  //子节点path的第一个byte的集合
    wildChild   bool   //是否通配符
    nType    nodeType //节点类型
    priority   uint32  
    children  []*node  //子节点
    handlers  HandlersChain   //handle如果不为nil,则说明是一个路径字符串的终点!!!
    fullPath   string
}

type nodeType   uint8

const (
  static nodeType =iota // default  普通节点
  root  //根节点
  param //参数节点 /user/{id},id 就是一个参数节点
  catchAll //通配符
)

gin注册路由过程:

如下左边是/sex,/sleep,/son的内部呈现, 右边是插入/seed 后内部的呈现:

gin路由

简单的来说每一个注册的 url 都会通过 / 切分为 n 个树节点(httprouter 会有一些区别,会存在根分裂),然后挂到相应 method 树上去,所以业务中有几种不同的 method 接口,就会产生对应的前缀树。

在 httprouter 中,节点被分为 4 种类型:

  • static - 静态节点,/user /api 这种
  • root - 根结点
  • param - 参数节点 /user/{id},id 就是一个参数节点
  • catchAll - 通配符

其实整个匹配的过程也比较简单,通过对应的 method 拿到前缀树,然后开始进行一个广度优先的匹配。

这里值得学习的一点是,httprouter 对下级节点的查找进行了优化,简单来说就是把当前节点的下级节点的首字母维护在本身,匹配时先进行索引的查找。

注意:

gin中相同http方法的路由树中,参数结点和静态结点是冲突的,也就是:

get 方法,如果存在了 /user/:name这样的路径,就不能再添加/user/getName这样的静态路径,否则会报冲突;

微服务统一配置管理

在应用程序的运行生命周期中,最直接的关系之一就是应用的配置读取和更新。

为什么需要分布式配置中心

分布式配置中心的优势在于它可以将多个模块系统的各配置文件,全部配置在配置中心统一管理,无需重启服务器即可动态刷新加载配置信息。

此外,分布式配置中心还可以实现不同环境配置隔离(开发、测试、预发布、灰度/线上),高性能、高可用性,请求量多、高并发等特性。

分布式配置中心技术选型

  • 目前主流的分布式配置中心:spring cloud config、apollo和nacos;spring cloud config属于java的spring体系,我们就考虑apollo和nacos
  • apollo和nacos:apollo是携程开源、nacos是阿里开源
    • a、apollo大而全,功能完善;nacos小而全,可以对比成django和flask的区别
    • b、部署nacos更加简单
    • c、nacos不止支持配置中心还支持服务注册和发现
    • d、都支持各种语言,不过apollo是第三方支持的,nacos是官方支持各种语言
  • nacos官网:https://nacos.io/zh-cn/
  • apollo的git地址:https://github.com/apolloconfig/apollo
  • apollo和nacos功能对比
功能点 apollo nacos
开源时间 2016.5 2018.6
配置实时推送 支持(http长轮询) 支持(http长轮询)
配置回滚 支持 支持
灰度发布 支持 待支持
权限管理 支持 支持
多集群 支持 支持
监听查询 支持 支持
多语言 主流语言 主流语言(官方支持)
通讯协议 http http

Spring Cloud Nacos

优点

1)开箱即用,适用于dubbo,spring cloud等

2)AP模型,数据最终一致性

3)注册中心,配置中心二合一(二合一也不一定是优点),提供控制台管理

4)纯国产,各种有中文文档,久经双十一考验

缺点

1)刚刚开源不久,社区热度不够,依然存在bug

Spring Cloud Eureka:

优点

1)Spring Cloud 官方推荐

2)AP模型,数据最终一致性

3)开箱即用,具有控制台管理

缺点

1)客户端注册服务上报所有信息,节点多的情况下,网络,服务端压力过大,且浪费内存

2)客户端更新服务信息通过简单的轮询机制,当服务数量巨大时,服务器压力过大。

3)集群伸缩性不强,服务端集群通过广播式的复制,增加服务器压力

4)Eureka2.0 闭源(Spring Cloud最新版本还是使用的1.X版本的Eureka)

golang的土著配置组件viper

除了分布式微服务的配置中心nacos、eureka这些。

golang中,也自己的土著配置中心 viper ,这个使用评率很高。

可以说,viper 是golang的土著配置组件中,使用率非常高的一个。

viper 结合 consul等组件,也能具备分布式配置管理能力。

通过 viper实现统一配置管理功能

1、viper的介绍

viper是go一个强大的流行的配置解决方案的库。有大量项目都使用该库,比如hugo, docker等。

Viper 是一个完整的 Go 应用程序配置解决方案,优势就在于开发项目中你不必去操心配置文件的格式而是让你腾出手来专注于项目的开发。

viper 其特性如下:

  • 支持 JSON/TOML/YAML/HCL/envfile/Java properties 等多种格式的配置文件;
  • 可以设置监听配置文件的修改,修改时自动加载新的配置;
  • 从环境变量、命令行选项和io.Reader中读取配置;
  • 从远程配置系统中读取和监听修改,如 etcd/Consul;
  • 代码逻辑中显示设置键值

注:Viper让需要重启服务器才能使配置生效的日子一去不复返!!!这才是VIper最大的魅力

它基本上可以处理所有类型的配置需求和格式, viper支持功能

  • 设置默认配置
  • 支持各种配置文件,如JSON,TOML, YAML, HCL, envfile和Java属性配置文件
  • 支持监听文件变化以及重新读取配置
  • 支持从环境变量读取配置
  • 支持从远程配置系统(etcd或Consul)读取配置,并能监听远程配置修改
  • 支持从命令行标志Flag读取配置,比如搭配cobra使用
  • 支持读取缓冲区数据

Viper主要为我们做以下工作:

  • 查找、加载和解组JSON、TOML、YAML、HCL、INI、envfile或Java属性格式的配置文件。
  • 提供一种机制来为不同的配置选项设置默认值。
  • 提供一种机制来为通过命令行标志指定的选项设置覆盖值。
  • 提供别名系统,以便在不破坏现有代码的情况下轻松重命名参数。
  • 当用户提供了与默认值相同的命令行或配置文件时,很容易区分它们。

2、viper的安装和使用

viepr的安装很简单,直接再工程中使用go get命令安装即可

$ go get github.com/spf13/viper

2.1 Viper对象的创建

使用viper.New()函数创建一个Viper Struct,如:

viper := viper.New()

Viper的是viper库的主要实现对象, viper提供了下面的方法可以获取Viper实例:

func GetViper() *Viper
func New() *Viper
func NewWithOptions(opts ...Option) *Viper
func Sub(key string) *Viper

使用viper.GetViper()获取的为全局的Viper实例对象,默认使用viper包使用也是该全局Viper实例。

查看viper的源码,可以看到viper默认提供了一个全局的Viper实例:

var v *Viper
 
func init() {
    
    
	v = New()
}
 
// New returns an initialized Viper instance.
func New() *Viper {
    
    
	v := new(Viper)
	v.keyDelim = "."
	v.configName = "config"
	v.configPermissions = os.FileMode(0o644)
	v.fs = afero.NewOsFs()
	v.config = make(map[string]interface{
    
    })
	v.override = make(map[string]interface{
    
    })
	v.defaults = make(map[string]interface{
    
    })
	v.kvstore = make(map[string]interface{
    
    })
	v.pflags = make(map[string]FlagValue)
	v.env = make(map[string][]string)
	v.aliases = make(map[string]string)
	v.typeByDefValue = false
	v.logger = jwwLogger{
    
    }
 
	v.resetEncoding()
 
	return v
}

New和NewWithOptions为我们提供了创建实例的方法

func New1() *viper.Viper {
    
    
	return viper.New()
}
 
func New2() *viper.Viper {
    
    
	return viper.NewWithOptions()
}

Sub为我们读取子配置项提供了一个新的实例Viper

v := viper.Sub("db")
url := v.Get("url")
log.Printf("mysql url:%s\n", url)

2.2 预设一些默认配置

通过SetDefault 防范,可以 配置默认值

viper.SetDefault("key1","value1")
viper.SetDefault("key2","value2")


viper.SetDefault("ContentDir", "content")
viper.SetDefault("Taxonomies", map[string]string{
    
    "tag": "tags", "category": "categories"})
 
viper.SetDefault("redis.port", 6379)
viper.SetDefault("mysql.url", "root:root@tcp(127.0.0.1:3306)/stock?charset=utf8mb4&parseTime=True&loc=Local")

2.3 key大小写问题

viper的配置的key值是不区分大小写,如:

# 小写的key
viper.set("test","this is a test value")
# 大写的key,也可以读到值
fmt.Println(viper.get("TEST"))//输出"this is a test value"

2.4 从配置文件读取

直接指定文件路径

viper.SetConfigFile("./config.yaml")
viper.ReadInConfig()
fmt.Println(viper.Get("test"))

多路径查找

viper.SetConfigName("config")     // 配置文件名,不需要后缀名
viper.SetConfigType("yml")            // 配置文件格式
viper.AddConfigPath("/etc/appname/")  // 查找配置文件的路径
viper.AddConfigPath("$HOME/.appname") // 查找配置文件的路径
viper.AddConfigPath(".")              // 查找配置文件的路径
err := viper.ReadInConfig()           // 查找并读取配置文件
if err != nil {
    
                           // 处理错误
    panic(fmt.Errorf("Fatal error config file: %w n", err))
}

读取配置文件时,可能会出现错误,如果我们想判断是否是因为找不到文件而报错的,可以判断err是否为ConfigFileNotFoundError

if err := viper.ReadInConfig(); err != nil {
    
    
	if _, ok := err.(viper.ConfigFileNotFoundError); ok {
    
    
		
	} else {
    
    
		
	}
}

下面我们看一下操作实例, 先看我们的配置文件app.yml文件:

app:
  name: viper-test
  mode: dev
 
db:
  mysql:
    url: "root:root@tcp(127.0.0.1:3306)/stock?charset=utf8mb4&parseTime=True&loc=Local"
  redis:
    host: 127.0.0.1
    port:  6067
    db: 0
    passwd: 123456

初始化配置

func InitConfig() (*viper.Viper, error) {
    
    
  v := viper.New()
  v.AddConfigPath(".")					// 添加配置文件搜索路径,点号为当前目录
  v.AddConfigPath("./configs")		// 添加多个搜索目录
  v.SetConfigType("yaml")				// 如果配置文件没有后缀,可以不用配置
  v.SetConfigName("app.yml")			// 文件名,没有后缀
	
    // v.SetConfigFile("configs/app.yml")
  
  // 读取配置文件
  if err := v.ReadInConfig(); err == nil {
    
    
		log.Printf("use config file -> %s\n", v.ConfigFileUsed())
	} else {
    
    
		return nil,err
	}
  return v, nil
}

首先这里我们添加一个配置文件搜索路径,点号表示当前路径,搜索路径可以添加多个然后设置了配置文件类型,这里我们设置文件类型为yaml,

接着我们设置了配置文件名称,这个文件可以从配置的搜索路径从查找。

最后我们通过提供的ReadInConfig()函数读取配置文件

读取配置文件

// 通过.号来区分不同层级,来获取配置值
log.Printf("app.mode=%s\n", v.Get("app.mode"))
log.Printf("db.mysql.url=%s\n", v.Get("db.mysql.url"))
log.Printf("db.redis.host=%s\n", v.GetString("db.redis.host"))
log.Printf("db.redis.port=%d\n", v.GetInt("db.redis.port"))
 
// 使用Sub获取子配置,然后获取配置值
v2 := v.Sub("db")
log.Printf("db.mysql.url:%s\n", v2.Sub("mysql").GetString("url"))
log.Printf("db.redis.host:%s\n", v2.Sub("redis").GetString("host"))
log.Printf("db.redis.port:%s\n", v2.Sub("redis").GetInt("port"))

viper还提供了如下获取类型获取配置项值:

注: 其中重要的一个函数IsSet可以用来判断某个key是否被设置

2.5 支持哪些文件格式

我们一直在说,viper支持多种不同格式的配置文件,到底是哪些格式呢?如下:

  • json
  • toml
  • yaml
  • yml
  • properties
  • props
  • prop
  • hcl
  • tfvars
  • dotenv
  • env
  • ini

3、写配置文件

除了读取配置文件外,viper也支持将配置值写入配置文件,viper提供了四个函数,用于将配置写回文件。四个函数如下:

  • WriteConfig

WriteConfig函数会将配置写入预先设置好路径的配置文件中,如果配置文件存在,则覆盖,如果没有,则创建。

  • SafeWriteConfig

SafeWriterConfig与WriteConfig函数唯一的不同是如果配置文件存在,则会返回一个错误。

  • WriteConfigAs

WriteConfigAs与WriteConfig函数的不同是需要传入配置文件保存路径,viper会根据文件后缀判断写入格式。

  • SafeWriteConfigAs

SafeWriteConfigAs与WriteConfigAs的唯一不同是如果配置文件存在,则返回一个错误。

使用SafeWriteConfig()和WriteConfig()时,可以先设定SetConfigFile()设定配置文件的路径。

下面是一个配置写入的示例:

v := New1()
v.SetConfigFile("./hello.yml")
 
log.Printf("config path:%+v\n", v.ConfigFileUsed())
 
v.SetDefault("author","CKeen")
v.SetDefault("email", "[email protected]")
 
v.Set("hello", "foo")
 
v.Set("slice", []string {
    
    "slice1","slice2","slice3"})
 
v.SetDefault("test.web", "https://ckeen.cn")
 
v.WriteConfig()
 
//v.WriteConfigAs("./hello.yml")

如果使用SafeWriteConfigAs()或者WriteConfigAs()方法,则直接传入配置文件路径即可。

4、从环境变量读取

viper支持环境变量的函数:

func (v *Viper) AutomaticEnv()			// 开启绑定环境变量
func (v *Viper) BindEnv(input ...string) error		// 绑定系统中某个环境变量
func (v *Viper) SetEnvKeyReplacer(r *strings.Replacer)
func (v *Viper) SetEnvPrefix(in string)

要让viper读取环境变量,有两种方式:

  • 方式一:调用AutomaticEnv函数,开启环境变量读取
fmt.Println(viper.Get("path"))
//开始读取环境变量,如果没有调用这个函数,则下面无法读取到path的值
viper.AutomaticEnv()
//会从环境变量读取到该值,注意不用区分大小写
fmt.Println(viper.Get("path"))
  • 方式二:使用BindEnv绑定某个环境变量
//将p绑定到环境变量PATH,注意这里第二个参数是环境变量,这里是区分大小写的
viper.BindEnv("p", "PATH")
//错误绑定方式,path为小写,无法读取到PATH的值
//viper.BindEnv("p","path")
fmt.Println(viper.Get("p"))//通过p可以读取PATH的值

使用函数SetEnvPrefix可以为所有环境变量设置一个前缀,这个前缀会影响AutomaticEnvBindEnv函数

os.Setenv("TEST_PATH","test")
viper.SetEnvPrefix("test")
viper.AutomaticEnv()
//无法读取path的值,因为此时加上前缀,viper会去读取TEST_PATH这个环境变量的值
fmt.Println(viper.Get("path"))//输出:nil
fmt.Println(viper.Get("test_path"))//输出:test

环境变量大多是使用下划号(_)作为分隔符的,如果想替换,可以使用SetEnvKeyReplacer函数,如:

//设置一个环境变量
os.Setenv("USER_NAME", "test")
//将下线号替换为-和.
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
//读取环境变量
viper.AutomaticEnv()
fmt.Println(viper.Get("user.name"))//通过.访问
fmt.Println(viper.Get("user-name"))//通过-访问
fmt.Println(viper.Get("user_name"))//原来的下划线也可以访问

默认的情况下,如果读取到的环境变量值为空(注意,不是环境变量不存在,而是其值为空),会继续向优化级更低数据源去查找配置,如果想阻止这一行为,让空的环境变量值有效,则可以调用AllowEmptyEnv函数:

viper.SetDefault("username", "admin")
viper.SetDefault("password", "123456")
//默认是AllowEmptyEnv(false),这里设置为true
viper.AllowEmptyEnv(true)
viper.BindEnv("username")
os.Setenv("USERNAME", "")
fmt.Println(viper.Get("username"))//输出为空,因为环境变量USERNAME空
fmt.Println(viper.Get("password"))//输出:123456

5、从命令行工具的选项参数Flags读取

viper主要提供了以下四个方法,可以绑定行参数的输出的选项值:

func (v *Viper) BindFlagValue(key string, flag FlagValue) error
func (v *Viper) BindFlagValues(flags FlagValueSet) (err error)
func (v *Viper) BindPFlag(key string, flag *pflag.Flag) error
func (v *Viper) BindPFlags(flags *pflag.FlagSet) error

viper可以和解析命令行库相关flag库一起工作,从命令行读取配置,两种方式:

  • viper内置了对pflag库的支持
  • viper留有接口让我们可以支持扩展其他的flag库

viper + pflag 结合使用

pflag 是一个 Go 语言的命令行参数解析库,它可以方便地解析命令行参数并将其转换为 Go 语言中的变量。

相比标准库中的 flag 包,pflag 包提供了更多的功能和选项,例如支持短选项、长选项、默认值、必选参数、可选参数等。

pflag 包的使用方法和 flag 包类似,但更加灵活和方便。

你可以使用 go get 命令来安装 pflag 包:

go get github.com/spf13/pflag

viper + pflag 结合使用的案例如下:

pflag.Int("port", 8080, "server http port")
pflag.Parse()
viper.BindPFlags(pflag.CommandLine)
fmt.Println(viper.GetInt("port"))//输出8080

Go 语言标准库中的 flag 包

如果我们没有使用pflag库,但又想让viper帮我们读取命令行参数呢?

Go 语言标准库中的 flag 包提供了命令行参数解析的功能。

flag 包 可以方便地解析命令行参数并将其转换为 Go 语言中的变量。

flag 包支持短选项和长选项,还可以设置默认值和解析必选参数。

使用 flag 包非常简单,你只需要在程序中定义需要解析的参数,然后调用 flag.Parse() 函数进行解析即可。

以下是一个示例:

package main

import (
    "flag"
    "fmt"
)

func main() {
    
    
    var name string
    var age int
    flag.StringVar(&name, "name", "world", "a string")
    flag.IntVar(&age, "age", 18, "an int")
    flag.Parse()
    fmt.Printf("Hello, %s! You are %d years old.\n", name, age)
}

在上面的例子中,我们定义了两个需要解析的参数 name 和 age,分别是字符串类型和整数类型。

使用 flag.StringVar()flag.IntVar() 函数将参数与变量绑定,并设置默认值和说明信息。

最后调用 flag.Parse() 函数进行解析,即可获取命令行参数并输出结果。

如何测试呢?

测试的方式之一: 通过命令行

go run main.go -name =张三  -age=30

测试的方式之一: 通过goland

执行结果:

flag包绑定解析参数有三种定义方式

方式一:flag.xxx()

例如:flag.Int, flag.String, 返回解析变量类型的指针

package main

import (
    "flag"
    "fmt"
)

func main() {
    
    
    host := flag.String("host", "127.0.0.1", "请输入host地址")
    port := flag.Int("port", 3306, "请输入端口号")
    flag.Parse() // 解析参数
    fmt.Printf("%s:%d\n", *host, *port)
}

执行: go run main.go -host=127.0.0.1 -port=3306

输出:127.0.0.1:3306

当然你也可以直接执行go run main.go, 这时候就会使用你的默认值

方式二: flag.XxxVar()

在方式一后面多了个Var

例如:flag.IntVar, flag.StringVar

package main

import (
    "flag"
    "fmt"
)
var host string
var port int

func init() {
    
     // 每个文件会自动执行的函数
    flag.StringVar(&host, "host", "127.0.0.1", "请输入host地址")
    flag.IntVar(&port, "port", 3306, "请输入端口号")
}

func main() {
    
    
    flag.Parse() // 解析参数
    fmt.Printf("%s:%d\n", host, port)
}

执行结果同上

3.自定义参数解析flag.Var(),

我们可以看下flag.go源码:

func Var(value Value, name string, usage string) {
    
    
    CommandLine.Var(value, name, usage)
}

使用flag.Var函数第一个参数我们需要传入一个Value类型的值,Value是一个接口类型,定义了两个方法,

type Value interface {
    
    
    String() string 
    Set(string) error  //Set接口决定了如何解析flag的值
}

接下来我们去实现这两个方法:

package main

import (
    "flag"
    "fmt"
    "strings"
)

// 自定义类型
type HandsomeBoys []string

// 实现String()方法
func (h *HandsomeBoys) String() string {
    
    
    return fmt.Sprintf("%v", *h)
}

// 实现Set方法,  Set接口决定了如何解析flag的值
func (h *HandsomeBoys) Set(s string) error {
    
    
    for _, v := range strings.Split(s, ",") {
    
    
        *h = append(*h, v)
    }
    return nil
}

// 定义一个HandsomeBoys类型的变量
var boys HandsomeBoys

func init() {
    
    
    // 绑定变量boys
    flag.Var(&boys, "boys", "请输入一组帅气的男孩名称:-boys=彭于晏,吴彦祖")
}

func main() {
    
    
    flag.Parse()
    fmt.Println(boys)
}

运行代码: go run main.go -boys=彭于晏,吴彦祖

viper + flag 结合使用

flag 和 viper 都是 Go 语言中常用的命令行参数解析库,它们可以方便地解析命令行参数并将其转换为 Go 语言中的变量。通常情况下,我们可以使用 flag 包来解析命令行参数,而使用 viper 包来解析配置文件。在实际开发中,我们可能会同时使用命令行参数和配置文件来配置程序,这时就可以将 flag 和 viper 结合起来使用。

下面是一个示例程序,演示了如何使用 flag 和 viper 结合使用:

package main

import (
    "flag"
    "fmt"
    "github.com/spf13/viper"
)

func main() {
    
    
    // 定义命令行参数
    var name string
    var age int
    flag.StringVar(&name, "name", "", "a string")
    flag.IntVar(&age, "age", 0, "an int")

    // 解析命令行参数
    flag.Parse()

    // 加载配置文件
    viper.SetConfigName("config") // 配置文件名
    viper.AddConfigPath(".")      // 配置文件路径
    err := viper.ReadInConfig()
    if err != nil {
    
    
        fmt.Println("Error reading config file:", err)
    }

    // 读取配置文件中的参数
    viper.SetDefault("name", "world")
    viper.SetDefault("age", 18)
    name = viper.GetString("name")
    age = viper.GetInt("age")

    // 输出结果
    fmt.Printf("Hello, %s! You are %d years old.\n", name, age)
}

在上面的例子中,我们首先定义了命令行参数 name 和 age,并使用 flag 包解析命令行参数。

然后使用 viper 包加载配置文件,并读取配置文件中的参数。

注意,我们使用 viper.SetDefault() 函数设置了默认值,以防配置文件中没有定义相应的参数。

最后输出结果。这样,我们就可以通过命令行参数和配置文件来配置程序了。

6、从远程key/value存储读取

在Viper中启用远程支持,需要在代码中匿名导入viper/remote这个包。

_ "github.com/spf13/viper/remote"

Viper将读取从Key/Value存储中的路径检索到的配置字符串(如JSONTOMLYAML格式)。

viper目前支持Consul/Etcd/firestore三种Key/Value的存储系统。

下面我来演示从etcd读取配置:

  • 首先我们安装crypt的工具
go get github.com/bketelsen/crypt/bin/crypt
  • 使用crypt的命令,将app.yml的文件添加到etcd
crypt set --endpoint=http://127.0.0.1:2379 -plaintext /config/app.yml /Users/ckeen/viper/configs/app.yml
        
上面的命令,设置本地的文件,到etcd
crypt set --endpoint=etcd 集群地址  -plaintext etcd路径  本地文件
  • 添加viper的操作远程资源的配置
_ "github.com/spf13/viper/remote"
  • 实现从远程读取配置
func InitConfigFromRemote() (*viper.Viper,error) {
    
    
    v := viper.New()
    // 远程配置
    v.AddRemoteProvider("etcd","http://127.0.0.1:2379","config/app.yml")
    //v.SetConfigType("json")
    v.SetConfigFile("app.yml")
    v.SetConfigType("yml")

    if err := v.ReadRemoteConfig(); err == nil {
    
    
        log.Printf("use config file -> %s\n", v.ConfigFileUsed())
    } else {
    
    
        return nil, err
    }
    return v, nil
}

func main(){
    
    

    v, err := InitConfigFromRemote()
    if err != nil {
    
    
        log.Printf("read remote error:%+v\n")
    }

    log.Printf("remote read app.mode=%+v\n", v.GetString("app.mode"))
    log.Printf("remote read db.mysql.url=%+v\n", v.GetString("db.mysql.url"))
}
  • 测试打印结果

7、监听配置变化

viper提供如下两种监听配置的函数,一个是本地的监听和一个远程监听的:

func (v *Viper) WatchConfig()
func (v *Viper) WatchRemoteConfig() error
func (v *Viper) WatchRemoteConfigOnChannel() error

7.1 监听本地配置文件变化

我们主要看一下监听本地文件变更的示例

v, err := InitConfig()
if err != nil {
    
    
log.Fatalf("viper读取失败, error:%+v\n",err)
}
 
// 监听到文件变化后的回调
v.OnConfigChange(func(e fsnotify.Event) {
    
    
  fmt.Println("Config file changed:", e.Name)
  fmt.Println(v.Get("db.redis.passwd"))
})
 
v.WatchConfig()
 
// 阻塞进程退出
time.Sleep(time.Duration(1000000) * time.Second)

我们使用前面的InitConfig()方法来初始化本地文件读取配置,然后设定了监听函数,最后使用WatchConfig()开启本地文件监听。

当我们修改本地配置configs/app.yml的db.redis.passwd的值,然后保存后,我们可以看到控制台有打印最新修改后的值,不要我们重新去获取。

7.2 监听远程配置变化

除了能够监听本地的配置变化,viper 支持监听远程配置变化。

Viper 提供了一个 RemoteConfig 方法,可以用来加载远程配置文件,例如从一个 HTTP 或者 ETCD 等远程配置中心加载配置文件。

在加载远程配置文件之后,你可以使用 WatchRemoteConfig 方法来监视配置文件的变化,当远程配置文件发生变化时,Viper 会自动重新加载配置文件,并触发相应的回调函数。

下面是一个示例代码:

viper.AddRemoteProvider("etcd", "http://localhost:2379", "/config/app.yaml")
viper.SetConfigType("yaml")

err := viper.ReadRemoteConfig()
if err != nil {
    
    
    // 处理错误
}

viper.WatchRemoteConfigOnChannel()

在这个示例代码中,我们使用了 AddRemoteProvider 方法来添加一个 ETCD 远程配置中心作为 Viper 的配置源,并指定了要加载的配置文件路径。

然后使用 ReadRemoteConfig 方法来加载远程配置文件。

最后使用 WatchRemoteConfigOnChannel 方法来监视配置文件的变化,当配置文件发生变化时,Viper 会自动重新加载配置文件,并触发相应的回调函数。

通过 Nacos实现统一配置管理功能

SpringCloud 分布式配置中心选型

目前最主流的分布式配置中心主要有

  • spring cloud config
  • apollo
  • nacos

etcd 和zookeeper , 也作为配置中心使用,但是没有 nacos 普通。

一般而言,在spring cloud属于spring体系, 就考虑apollo(携程)和nacos(阿里),都是目前比较流行且维护活跃的2个配置中心;

a . apollo大而全, 功能完善, nacos小而全;

b . 部署nacos更加简单;

c .nacos不止支持配置中心,还支持服务注册和发现;

d . 都支持各种语言, 不过apollo是第三方支持的,nacos是官方支持各种语言;

nacos很活跃, 不过看的出来nacos想要构建的生态野心更大, 不过收费意图明显;

另外,nacos 属于 SpringCloud alibaba 框架的核心组件。

用java+go 多语言 云原生微服务架构中配置中心的 位置

上个月尼恩指导 一个6年小伙伴简历,使用java+go 多语言 云原生微服务架构,帮助 6年小伙,收60W年薪

主要的架构图图如下:

在这个架构中, 用java+go 多语言 云原生微服务架构中的分布式 配置中心 ,是nacos

而go 中的viper框架,没有对 nacos 提供支持 所以,咱们自己实现,对 nacos 的支持

Nacos作为配置中心使用

为什么使用Nacos

“因为现在主流公司已经开始推荐使用nacos了,nacos的两大主要作用:

  • 一个是服务注册发现,
  • 一个是配置管理。

Nacos是什么?

Nacos是一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。

它提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据及流量管理 。

Nacos的结构和组件

Nacos的结构和组件包括:

  • 服务管理:实现服务CRUD,域名CRUD,服务健康状态检查,服务权重管理等功能。
  • 配置管理:实现配置管CRUD,版本管理,灰度管理,监听管理,推送轨迹,聚合数据等功能。
  • 元数据管理:提供元数据CURD 和打标能力。
  • 插件机制:实现三个模块可分可合能力,实现扩展点SPI机制。
  • 事件机制:实现异步化事件通知,sdk数据变化异步通知等逻辑。
  • 日志模块:管理日志分类,日志级别,日志可移植性(尤其避免冲突),日志格式,异常码+帮助文档。
  • 回调机制:sdk通知数据,通过统一的模式回调用户处理。

  • 服务管理:实现服务CRUD,域名CRUD,服务健康状态检查,服务权重管理等功能
  • 配置管理:实现配置管CRUD,版本管理,灰度管理,监听管理,推送轨迹,聚合数据等功能
  • 元数据管理:提供元数据CURD 和打标能力
  • 插件机制:实现三个模块可分可合能力,实现扩展点SPI机制
  • 事件机制:实现异步化事件通知,sdk数据变化异步通知等逻辑
  • 日志模块:管理日志分类,日志级别,日志可移植性(尤其避免冲突),日志格式,异常码+帮助文档
  • 回调机制:sdk通知数据,通过统一的模式回调用户处理。接口和数据结构需要具备可扩展性
  • 寻址模式:解决ip,域名,nameserver、广播等多种寻址模式,需要可扩展
  • 推送通道:解决server与存储、server间、server与sdk间推送性能问题
  • 容量管理:管理每个租户,分组下的容量,防止存储被写爆,影响服务可用性
  • 流量管理:按照租户,分组等多个维度对请求频率,长链接个数,报文大小,请求流控进行控制
  • 缓存机制:容灾目录,本地缓存,server缓存机制。容灾目录使用需要工具
  • 启动模式:按照单机模式,配置模式,服务模式,dns模式,或者all模式,启动不同的程序+UI
  • 一致性协议:解决不同数据,不同一致性要求情况下,不同一致性机制
  • 存储模块:解决数据持久化、非持久化存储,解决数据分片问题
  • Nameserver:解决namespace到clusterid的路由问题,解决用户环境与nacos物理环境映射问题
  • CMDB:解决元数据存储,与三方cmdb系统对接问题,解决应用,人,资源关系
  • Metrics:暴露标准metrics数据,方便与三方监控系统打通
  • Trace:暴露标准trace,方便与SLA系统打通,日志白平化,推送轨迹等能力,并且可以和计量计费系统打通
  • 接入管理:相当于阿里云开通服务,分配身份、容量、权限过程
  • 用户管理:解决用户管理,登录,sso等问题
  • 权限管理:解决身份识别,访问控制,角色管理等问题
  • 审计系统:扩展接口方便与不同公司审计系统打通
  • 通知系统:核心数据变更,或者操作,方便通过SMS系统打通,通知到对应人数据变更
  • OpenAPI:暴露标准Rest风格HTTP接口,简单易用,方便多语言集成
  • Console:易用控制台,做服务管理、配置管理等操作
  • SDK:多语言sdk
  • Agent:dns-f类似模式,或者与mesh等方案集成
  • CLI:命令行对产品进行轻量化管理,像git一样好用

Nacos的安装和使用

安装和部署特别简单,支持单机部署和集群部署,详情可参考nacos的官网。

部署完成之后,登录进入

可以看到配置管理里边的配置列表,页面长这个样子:

可以看到配置管理里边的配置列表的一个详情,页面长这个样子:

配置数据的三个核心概念:Namespace、Group、Data ID

命名空间(Namespace) :环境隔离

Namespace 的常用场景之一是不同环境的配置隔离。例如

  • 开发环境
  • 测试环境
  • 生产环境的资源(如配置、服务)隔离等。

不同的命名空间下,可以存在相同的 Group 或 Data ID 的配置。

不同的命名空间下,可以存在相同名称的配置分组(Group)或配置集。

当然,这个不是绝对的。。

也可以用来进行租户隔离。

配置分组(Group):项目隔离

配置分组的常见场景:不同的应用或组件使用了相同的配置类型,如 database_url 配置和 MQ_topic 配置。

配置分组就是对配置集进行分组。Nacos中的一组配置集,是组织配置的维度之一。

通过一个有意义的字符串(如 Buy 或 Trade )对配置集进行分组,从而区分 Data ID 相同的配置集。

当您在 Nacos 上创建一个配置时,如果未填写配置分组的名称,则配置分组的名称默认采用 DEFAULT_GROUP

配置集( Data ID):功能隔离

一组相关或者不相关的配置项的集合称为配置集。Data ID 通常用于组织划分系统的配置集。

每个配置集都可以被一个有意义的名称标识,就是配置集的ID即Data ID。

配置集( Data ID)的常见场景 : 功能隔离。

在系统中,一个配置文件通常就是一个配置集,包含了系统各个方面的配置。

例如,一个配置集可能包含了数据源、线程池、日志级别等配置项。

从功能视角,可以隔离出: 数据源配置集, 线程池配置集,等等。

配置集包含一个一个的配置项

配置集中包含的一个个配置内容就是配置项。

一个具体的可配置的参数与其值域,通常以 param-key=param-value 的形式存在。

例如我们常配置系统的日志输出级别(logLevel=INFO|WARN|ERROR) 就是一个配置项。

go nacos sdk 的使用

Nacos-sdk-go是Nacos的Go语言客户端sdk ,它实现了服务发现和动态配置的功能

  • nacos-sdk-go地址:https://github.com/nacos-group/nacos-sdk-go
  • nacos-sdk-go中文文档地址:https://github.com/nacos-group/nacos-sdk-go/blob/master/README_CN.md
  • go nacos需求分析
    • ①.能获取到配置
    • ②.能监听到配置文件的修改
    • ③.nacos即是一个配置中心,也是一个注册中心

Nacos-sdk-go 客户端组件的安装

使用go get安装SDK:

$ go get -u github.com/nacos-group/nacos-sdk-go/v2

Nacos-sdk-go 客户端组件的使用限制

  • 支持Go>=v1.15版本
  • 支持Nacos>2.x版本

nacos-sdk-go 的官方demo

nacos-sdk-go 的官方demo

第一步: 配合 nacos服务器,创建 serverConfig 对象

// 创建serverConfig 的例子
serverConfigs := []constant.ServerConfig{
    
    
    *constant.NewServerConfig(
        "console1.nacos.io",
        80,
        constant.WithScheme("http"),
        constant.WithContextPath("/nacos"),
    ),
    *constant.NewServerConfig(
        "console2.nacos.io",
        80,
        constant.WithScheme("http"),
        constant.WithContextPath("/nacos"),
    ),
}


// 创建serverConfig 的第2个例子
serverConfigs := []constant.ServerConfig{
    
    
    {
    
    
        IpAddr:      "console1.nacos.io",
        ContextPath: "/nacos",
        Port:        80,
        Scheme:      "http",
    },
    {
    
    
    	IpAddr:      "console2.nacos.io",
    	ContextPath: "/nacos",
    	Port:        80,
        Scheme:      "http",
    },
}

第二步: 配合 nacos 客户端,创建 clientConfig对象

// 创建clientConfig
clientConfig := constant.ClientConfig{
    
    
	NamespaceId:         "e525eafa-f7d7-4029-83d9-008937f9d468", // 如果需要支持多namespace,我们可以创建多个client,它们有不同的NamespaceId。当namespace是public时,此处填空字符串。
	TimeoutMs:           5000,
	NotLoadCacheAtStart: true,
	LogDir:              "/tmp/nacos/log",
	CacheDir:            "/tmp/nacos/cache",
	LogLevel:            "debug",
}

// 创建clientConfig的另一种方式
clientConfig := *constant.NewClientConfig(
    constant.WithNamespaceId("e525eafa-f7d7-4029-83d9-008937f9d468"), //当namespace是public时,此处填空字符串。
    constant.WithTimeoutMs(5000),
    constant.WithNotLoadCacheAtStart(true),
    constant.WithLogDir("/tmp/nacos/log"),
    constant.WithCacheDir("/tmp/nacos/cache"),
    constant.WithLogLevel("debug"),
)

第三步:创建动态配置客户端

// 创建动态配置客户端
_, _ := clients.CreateConfigClient(map[string]interface{
    
    }{
    
    
	"serverConfigs": serverConfigs,
	"clientConfig":  clientConfig,
})

// 创建动态配置客户端的另一种方式 (推荐)
configClient, err := clients.NewConfigClient(
    vo.NacosClientParam{
    
    
        ClientConfig:  &clientConfig,
        ServerConfigs: serverConfigs,
    },
)

和配置客户端类似,服务发现客户端也是这么干的

// 创建服务发现客户端
_, _ := clients.CreateNamingClient(map[string]interface{
    
    }{
    
    
	"serverConfigs": serverConfigs,
	"clientConfig":  clientConfig,
})


// 创建服务发现客户端的另一种方式 (推荐)
namingClient, err := clients.NewNamingClient(
    vo.NacosClientParam{
    
    
        ClientConfig:  &clientConfig,
        ServerConfigs: serverConfigs,
    },
)

示例:go nacos实现配置读取与监听配置变化

示例功能:

  • go nacos实现配置读取
  • 监听配置变化
package main

import (
	"fmt"
	"github.com/nacos-group/nacos-sdk-go/v2/clients"
	"github.com/nacos-group/nacos-sdk-go/v2/common/constant"
	"github.com/nacos-group/nacos-sdk-go/v2/vo"
	"time"
)

func main() {
    
    
	sc := []constant.ServerConfig{
    
    
		{
    
    
			IpAddr: "192.168.56.121",
			Port:   8848,
		},
	}

	// 创建clientConfig
	cc := constant.ClientConfig{
    
    
		NamespaceId:         "dubbo", // 如果需要支持多namespace,我们可以场景多个client,它们有不同的NamespaceId。当namespace是public时,此处填空字符串。
		TimeoutMs:           5000,
		NotLoadCacheAtStart: true,
		LogDir:              "tmp/nacos/log", //去掉tmp前面的/,这样就会默认保存到当前项目目录下
		CacheDir:            "tmp/nacos/cache",
		LogLevel:            "debug",
	}

	configClient, err := clients.CreateConfigClient(map[string]interface{
    
    }{
    
    
		"serverConfigs": sc,
		"clientConfig":  cc,
	})
	if err != nil {
    
    
		panic(err)
	}

	content, err := configClient.GetConfig(vo.ConfigParam{
    
    
		DataId: "crazymaker-common-dev.yml",
		Group:  "DEFAULT_GROUP"})

	if err != nil {
    
    
		panic(err)
	}
	fmt.Println(content) //字符串 - yaml

	//监听配置修改
	err = configClient.ListenConfig(vo.ConfigParam{
    
    
		DataId: "crazymaker-common-dev.yml",
		Group:  "DEFAULT_GROUP",
		OnChange: func(namespace, group, dataId, data string) {
    
    
			fmt.Println("配置文件变化")
			fmt.Println("group:" + group + ", dataId:" + dataId + ", data:" + data)
		},
	})
	time.Sleep(3000 * time.Second)
}

运行代码,输入了配置文件里边的内容

CacheDir的作用:

  • 配置了CacheDir的时候,本地项目下会生成CacheDir的目录
  • 一旦nacos不可用,无法访问nacos;如果我们有配置CachaDir,那么就会读取本地配置信息

nacos go动态配置

类似于 数据库的 crud,以及变化通知

  • 发布配置:PublishConfig

使用nacos go动态发布配置

success, err := configClient.PublishConfig(vo.ConfigParam{
    
    
    DataId:  "dataId",
    Group:   "group",
    Content: "hello world!222222"})
  • 删除配置:DeleteConfig

使用nacos go动态删除配置

success, err = configClient.DeleteConfig(vo.ConfigParam{
    
    
    DataId: "dataId",
    Group:  "group"})
  • 获取配置:GetConfig

使用nacos go动态删除配置

content, err := configClient.GetConfig(vo.ConfigParam{
    
    
    DataId: "dataId",
    Group:  "group"})
  • 监听配置变化:ListenConfig

使用nacos go动态监听配置变化

err := configClient.ListenConfig(vo.ConfigParam{
    
    
    DataId: "dataId",
    Group:  "group",
    OnChange: func(namespace, group, dataId, data string) {
    
    
        fmt.Println("group:" + group + ", dataId:" + dataId + ", data:" + data)
	},
})
  • 取消配置监听:CancelListenConfig

使用nacos go取消配置监听

err := configClient.CancelListenConfig(vo.ConfigParam{
    
    
    DataId: "dataId",
    Group:  "group",
})
  • 搜索配置: SearchConfig

使用nacos go搜索配置

configPage,err := configClient.SearchConfig(vo.SearchConfigParam{
    
    
    Search:   "blur",
    DataId:   "",
    Group:    "",
    PageNo:   1,
    PageSize: 10,
})

监听配置变化演示:

上面的案例里边,已经有了 监听配置变化代码

在nacos 控制台,修改一下配置文件

发布一波,

咱们程序的控制台,就可以看到 , 监听到的数据

要注意的是:

这里返回的是整个的数据集,而不仅仅是变化了的那个数据项

《Golang 圣经》还有 5W字待发布

本文,仅仅是《Golang 圣经》 的第3部分。《Golang 圣经》后面的内容 更加精彩,涉及到高并发、分布式微服务架构、 WEB开发架构,具体请关注进展,请关注《技术自由圈》 公众号。

如果需要领取 《Golang 圣经》, 请关注《技术自由圈》 公众号,发送暗号 “领电子书” 。

最后,如果学习过程中遇到问题,可以来尼恩的 万人高并发社群《技术自由圈》中交流。

参考资料

推荐阅读:

Docker圣经:大白话说Docker底层原理,6W字实现Docker自由

K8S学习圣经:大白话说K8S底层原理,14W字实现K8S自由

SpringCloud Alibaba 学习圣经,10万字实现SpringCloud 自由

尼恩 架构笔记、面试题 的PDF文件更新,▼请到下面【技术自由圈】公号取 ▼

猜你喜欢

转载自blog.csdn.net/crazymakercircle/article/details/131234144