介绍
CloudWeGo 是由字节跳动推出的开源中间件集,可用于快速构建企业级云原生架构。CloudWeGo的特点是高性能、高可扩展性、高可靠性以及专注于微服务通信和治理。
基础知识
环境配置
- Go:Ubuntu 中安装 Go
- IDE:VSCode/Goland,下面是VSCode上的插件
- One Dark Pro
- Go:可能需要手动安装gopls
- Golang Tools
- Docker
- MySQL(publisher:“Weijan Chen”)
- Material Icon Theme
- YAML
- vscode-proto3
- Makefile Tools
- Thrift
- 终端工具:Oh My Zsh
- zsh -autosuggestions
- zsh-syntax-highlighting
- Docker
- 代码位置:
~/projects/gomall
demo-Hertz
使用Hertz
写一个demo
:
1
2
3
4
5
|
mkdir -p ~/projects/gomall/hello_world
cd ~/projects/gomall/hello_world
go mod init gomall/hello_world
go get -u github.com/cloudwego/hertz
touch main.go
|
去Hertz官网找到示例代码粘贴到main.go
1
2
|
go mod tidy
go run main.go
|
脚手架
IDL
IDL是微服务架构拆分后的接口协议,用于微服务间通信
IDL(interface description language,接口描述语言)是用来描述软件组件接口的一种计算机语言。IDL通过一种独立于编程语言的方式来描述接口,使得在不同平台上运行的对象和用不同语言编写的程序可以相互通信交流,比如一个组件是用C++写的,另一个组件是用Java写的。
IDL可以在RPC调用中保证双方遵循同一约定
- 标准化与一致性:IDL提供一种独立于具体实现的语言来描述数据结构和服务接口
- 跨语言支持:通过IDL编写的接口和数据结构能够被转换成多种目标语言的代码,使得不同语言开发的组件能有效地协同工作
- 版本控制与兼容性:IDL有助于管理API的变化,保证新旧版本之间的向后兼容性,减少因升级带来的客户端和服务端不匹配问题
- 简化开发流程:IDL工具可以自动生成序列化/反序列化代码,降低开发者手动编写这些复杂逻辑的工作量
通过使用IDL,不同的编程语言之间可以无缝地进行数据交换和服务调用,可以增强系统的可扩展性和互操作性。
Protocol Buffers(protobuf)
和thrift
就是比较常见的两种IDL
CWGO
cwgo是一个代码生成工具,可以很方便地在项目开发中帮我们生成客户端和服务端的代码,并且还支持自定义项目目录模板
cwgo 是 CloudWeGo All in one 代码生成工具,整合了 kitex 和 hertz 工具的优势,以提高开发者的编码效率和使用体验。
安装 cwgo
1
|
GOPROXY=https://goproxy.cn/,direct go install github.com/cloudwego/cwgo@latest
|
添加到环境变量
1
2
3
4
5
6
7
8
9
|
# 打开 .bashrc 或 .zshrc 文件
vi ~/.zshrc
# 添加到文件末尾
export PATH=$PATH:~/go/bin
# 保存并关闭文件
# 立即生效
source ~/.zshrc
|
cwgo提供了各种终端的一个自动补全功能:文档
1
2
3
4
5
|
# 在 Zsh 中临时支持补全
cd
mkdir autocomplete # You can choose any location you like
cwgo completion zsh > ./autocomplete/zsh_autocomplete
source ./autocomplete/zsh_autocomplete
|
输入cwgo即可看到cwgo提供的所有命令
Thriftgo
Thriftgo 是 go 语言实现的 thrift 编译器。它具有与 apache/thrift
编译器类似的命令行界面,并通过插件机制增强了功能,使其更加强大。
安装Thriftgo
1
|
GOPROXY=https://goproxy.cn/,direct GO111MODULE=on go install github.com/cloudwego/thriftgo@latest
|
Protoc
protobuf依赖protoc编译文件
1
2
3
|
sudo apt-get update
sudo apt-get install protobuf-compiler
protoc --version
|
下面演示使用两种IDL生成微服务应用
demo-thrift
- 编写
echo.thrift
1
2
3
|
mkdir -p ~/projects/gomall/idl
cd ~/projects/gomall/idl
touch echo.thrift
|
echo.thrift
内容
1
2
3
4
5
6
7
8
9
10
11
12
13
|
namespace go api
struct Request {
1: string message
}
struct Response {
1: string message
}
service Echo {
Response echo(1: Request req)
}
|
- 利用
echo.thrift
生成服务端代码:使用cwgo
工具,利用上面写的echo.thrift
生成RPC
服务的server
,指定go.mod
中的Go module
名,指定服务名
1
2
3
4
|
cd ~/projects/gomall
mkdir -p demo/demo_thrift
cd demo/demo_thrift
cwgo server --type RPC --module gomall/demo/demo_thrift --service demo_thrift --idl ../../idl/echo.thrift
|
生成代码的在当前目录下,业务逻辑在biz/service
目录下
修改业务代码echo.go
最后一行,直接返回请求信息
1
|
return &api.Response{Message: req.Message},nil
|
- 运行服务端
server
1
2
3
4
5
6
|
go mod tidy
cd ~/projects/gomall
go work init
cd ~/projects/gomall/demo/demo_thrift
go work use .
go run .
|
demo-proto3
- 编写
echo.proto
1
2
|
cd ~/projects/gomall/idl
touch echo.proto
|
echo.proto
内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
syntax = "proto3";
package pbapi;
option go_package = "/pbapi";
message Request {
string message = 1;
}
message Response {
string message = 1;
}
service Echo {
rpc Echo (Request) returns (Response) {}
}
|
- 利用
echo.proto
生成服务端代码:使用cwgo
工具,利用上面写的echo.proto
生成RPC
服务的server
,指定go.mod
中的Go module
名,指定服务名
1
2
3
4
|
cd ~/projects/gomall
mkdir -p demo/demo_proto
cd demo/demo_proto
cwgo server -I ../../idl --type RPC --module gomall/demo/demo_proto --service demo_proto --idl ../../idl/echo.proto
|
生成代码的在当前目录下,业务逻辑在biz/service
目录下
修改业务代码echo.go
最后一行,直接返回请求信息
1
|
return &pbapi.Response{Message: req.Message},nil
|
- 运行服务端
server
1
2
3
4
5
6
7
8
9
|
go mod tidy
# 上面初始化过工作区的话此处不必重新初始化
# ---
cd ~/projects/gomall
go work init
cd ~/projects/gomall/demo/demo_proto
# ---
go work use .
go run .
|
可以使用Makefile减少命令的重复输入
IDL cloudwego extend
cloudwego对IDL的一些扩展:hz
hz 是 Hertz 框架提供的一个用于生成代码的命令行工具。目前,hz 可以基于 thrift 和 protobuf 的 IDL 生成 Hertz 项目的脚手架。
服务注册与服务发现
介绍
为什么需要服务注册与服务发现
微服务拆分后,为了提高一组应用的整体性能和处理能力,通常部署在多台服务器上。
多台服务器之间的通信存在通信问题:假如B部署在多台服务器上,客户端A要调用B的服务,那么A如何知道B的地址
- 方法1:在A的代码或配置文件中写死一组的B服务所在服务器的IP;
- 存在问题:若B新增一台机器或已有机器IP发生变更,如何告知A,这只能人工介入解决
计算机科学中的所有问题,都可以通过增加一层来解决问题
- 方法2:在A和B之间创建一个中间层C,把不变更的C写入A的代码或配置中,A只与C通信。B实例的变更只需要告知C,C在内部维护了一组的实例B的列表。C作为服务B的统一入口分发请求
- 存在问题1:若每组服务都维护一个统一入口会增加维护成本
- 存在问题2:C本身的IP或域名是一种有限的资源
- 存在问题3:C本身会形成一个新的单点,这种设计会违背微服务拆分的初衷
- 方法3:在方法2的基础上进行优化,方法2是把C放到B侧,C既做服务注册也做服务发现。其实可以把C的功能放到A侧,C只提供存储实例的作用,当一个注册中心,B服务增减实例都告知C并存储。在A侧做服务发现,A会定时去C获取一次B的实例列表,从这一组列表中间选择一个调用B

如何选择注册中心
目前在Kitex中以接口形式可以兼容各种主流注册中心的中间件,具体选哪个注册中心可以按照自己的实际情况和习惯
Kitex[kaɪt’eks] 字节跳动内部的 Golang 微服务 RPC 框架

分布式系统经典的CAP理论
- Consistency:一致性
- Availability:可用性
- Partition Tolerance:分区容错性
在工程实践中,我们最多只能实现其二
demo
思路:
- 以
demo_proto
为服务端,改造一些服务注册的代码
- 利用
docker
启动一个consul
注册中心
- 编写一个客户端代码,发现
demo_proto
实例并进行接口调用
配置服务端的服务注册
Consul | CloudWeGo
- 下载
kitex
配置注册中心的sdk
1
2
|
cd ~/projects/gomall/demo/demo_proto
go get github.com/kitex-contrib/registry-consul
|
- 服务端配置注册中心:在
demo_proto
的main.go
中有kitex
启动的相关配置代码,修改main.go
相关代码,参考官网-newconsulregister
1
2
3
4
5
6
7
8
9
10
11
12
|
consul "github.com/kitex-contrib/registry-consul"
// 初始化console注册中心组件
r, err := consul.NewConsulRegister("127.0.0.1:8500")
if err != nil {
log.Fatal(err)
}
// 设置服务启动参数
opts = append(opts, server.WithRegistry(r))
// klog
|
cwgo
生成的代码中,有关配置注册的信息都在conf/conf.go
中,在demo_proto/conf/test/conf.yaml
中修改注册中心端口为8500
1
2
3
4
5
|
registry:
registry_address:
- 127.0.0.1:8500
username: ""
password: ""
|
- 使用
docker
启动consul
注册中心
1
2
|
cd ~/projects/gomall
touch docker-compose.yaml
|
docker-compose.yaml
内容
1
2
3
4
5
|
services:
consul:
image: 'hashicorp/consul'
ports:
- 8500:8500
|
启动consul
容器
Consul提供了一个WEB界面可以用来查看所有的节点,通过8500端口访问WEB管理界面
- 启动服务端
1
2
|
cd ~/projects/gomall/demo/demo_proto
go run .
|
编写客户端来发现服务并调用
- 编写客户端代码
1
2
3
4
|
cd ~/projects/gomall/demo/demo_proto
mkdir -p cmd/client
cd cmd/client
touch client.go
|
客户端代码参考官网文档 - newconsulresolver
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
package main
import (
"context"
"fmt"
"log"
"gomall/demo/demo_proto/kitex_gen/pbapi"
"gomall/demo/demo_proto/kitex_gen/pbapi/echo"
"github.com/cloudwego/kitex/client"
consul "github.com/kitex-contrib/registry-consul"
)
func main() {
r, err := consul.NewConsulResolver("127.0.0.1:8500")
if err != nil {
log.Fatal(err)
}
client, err := echo.NewClient("demo_proto", client.WithResolver(r))
if err != nil {
log.Fatal(err)
}
res, err := client.Echo(context.TODO(), &pbapi.Request{Message: "hello"})
if err != nil {
log.Fatal(err)
}
fmt.Printf("%v", res)
}
|
- 运行客户端
配置管理
介绍
常见的配置有以下三种
- 文件配置:将配置写在文件中,文件格式一般是
yaml
、json
或toml
- cwgo脚手架里默认已经提供了一套配置文件,用的是
yaml
格式
- 在
demo_proto/conf
目录下,conf.go
负责解析所有的配置文件,三个文件夹对应开发、生成和测试三个环境的配置,默认生效的是测试环境的配置
- 环境变量:通过环境变量来注入配置
- 配置中心:选择一个配置中心来存储所有的配置,修改配置之后可以即时生效。我们的应用会连接到配置中心,配置中心会提供比如
watch
的方式去监听某些配置,然后实时的把配置中心的更改推送到应用
kitex
提供了各种配置中心的适配,常见的配置中心有Etcd
、Consul
、Zookeeper
、Nacos
、Apollo
k8s
的底层存储元数据都是使用的etcd
,tecd
是cncf
(云原生计算基金会)的毕业项目,非常流行,稳定性有保障
- 配置中心的选择跟上一节的注册中心是类似的,很多情况下,配置中心跟注册中心都会选同一个,这样使用起来比较方便,维护成本也更低
java
开发的配置中心,如Zookeeper
、Nacos
也是比较流行的
- Kitex提供的几种配置中心集成的仓库
demo
下面以配置连接mysql
为例
- 修改配置文件:在
test
环境下conf.yaml
中,将mysql
的dsn
字段(数据源名称)的mysql user
、mysql password
、mysql host
、mysql database
修改为占位符
1
2
3
|
mysql:
# dsn: "gorm:gorm@tcp(127.0.0.1:3306)/gorm?charset=utf8mb4&parseTime=True&loc=Local"
dsn: "%s:%s@tcp(%s:3306)/%s?charset=utf8mb4&parseTime=True&loc=Local"
|
- 设置配置文件的读取:在
biz/dal/mysql/init.go
中,利用字符串格式化函数Sprintf
填充占位符,从系统环境变量中获取配置填充dsn
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
func Init() {
// 设置配置文件的读取:从系统环境变量中获取配置填充 dsn
dsn := fmt.Sprintf(conf.GetConf().MySQL.DSN,
os.Getenv("MYSQL_USER"),
os.Getenv("MYSQL_PASSWORD"),
os.Getenv("MYSQL_HOST"),
os.Getenv("MYSQL_DATABASE"),
)
DB, err = gorm.Open(mysql.Open(dsn),
...
)
...
}
|
- 通过
.env
文件写环境变量:在demo_proto
目录下新建.env
文件
也可以在命令行直接输入export MYSQL_USER=wyatt
来设置系统环境变量,这种方式设置的环境变量只在当前shell
有效
1
2
3
4
|
MYSQL_USER=root
MYSQL_PASSWORD=wyatt123
MYSQL_HOST=localhost
MYSQL_DATABASE=demo_proto
|
- 加载
.env
文件内容到系统环境中:下载 joho/godotenv库: Loads environment variables from .env files
1
2
|
cd ~/projects/gomall/demo/demo_proto
go get github.com/joho/godotenv
|
项目启动时调用godotenv
库并连接数据库:在demo_proto/main.go
的main
函数开头调用
1
2
3
4
5
6
7
|
// 项目启动时加载 .env文件内容到系统环境中
err := godotenv.Load()
if err != nil {
panic(err)
}
// 连接数据库
dal.Init()
|
项目启动时先不连接redis
:修改biz/dal/init.go
1
2
3
4
|
func Init() {
// redis.Init()
mysql.Init()
}
|
- 配置
mysql
的docker
容器:在gomall/docker-compose.yaml
中新增
1
2
3
4
5
6
7
8
9
10
11
12
|
services:
...
mysql:
# 实际使用时最好锁定版本号, latest不是一个最佳实践
image: "mysql:latest"
ports:
- 3306:3306
environment:
# mysql镜像要求必须设置密码
- MYSQL_ROOT_PASSWORD=wyatt123
# 容器启动时初始化一个数据库demo_proto
- MYSQL_DATABASE=demo_proto
|
- 启动
mysql
容器
1
2
|
cd ~/projects/gomall
docker compose up
|
- 启动服务端
1
2
3
|
cd ~/projects/gomall/demo/demo_proto
go mod tidy
go run .
|
- 测试
mysql
是否连接成功:在biz/dal/mysql/init.go
中新增代码,获取并打印mysql
版本号
1
2
3
4
5
6
7
8
9
10
11
12
13
|
type Version struct {
Version string
}
var v Version
err = DB.Raw("select version() as version").Scan(&v).Error
if err != nil {
panic(err)
}
fmt.Println("当前mysql版本为:" + v.Version)
|
重新启动服务端
ORM
原生sql官方标准库:sql package - database/sql - Go Packages,可读性好,但编写效率不高
思路
- 环境改造和确认:创建测试数据库,数据库名叫
demo_proto
,可以利用mysql
的docker
容器中的MYSQL_DATABASE
这个环境变量参数,在创建镜像启动时指定数据库名
- 创建数据模型:
biz
目录下新增model
文件,并且创建user
的数据模型
- 自动迁移:在
dai
目录(数据访问层)数据库初始化时支持数据库迁移,会用到gorm
的一个特性
- 测试代码:在
demo_proto
目录下新增一个目录和文件,来进行增删改查的操作
环境改造和确认
上一节已经完成
创建数据模型
Declaring Models | GORM
在biz
目录下创建model/user.go
文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
package model
import "gorm.io/gorm"
type User struct {
gorm.Model
Email string `gorm:"uniqueIndex;type:varchar(128) not null"`
Password string `gorm:"type:varchar(64) not null"`
}
// 指定表明为user,默认为users
func (User) TableName() string {
return "user"
}
|
启动gorm自动迁移
Migration | GORM
gorm自动迁移:自动根据数据模型创建表,还可以补全缺失的外键、列信息和索引等,但是它不会主动删除无用的列。除此之外它还有一些表操作、列操作、视图索引的操作
在数据库初始化时调用自动迁移函数,biz/dal/mysql/init.go
最后添加
1
|
DB.AutoMigrate(&model.User{})
|
重启服务端,在数据库可视化工具中查看users
表是否创建成功
测试代码
Create | GORM - The fantastic ORM library for Golang, aims to be developer friendly.
在demo_proto/cmd
下新增dbop/db.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
package main
func main() {
// 加载 .env文件内容到系统环境中
err := godotenv.Load()
if err != nil {
panic(err)
}
// 连接数据库
dal.Init()
// test CURD
// 增
mysql.DB.Create(&model.User{Email: "demo@example.com", Password: "*******"})
// 改
mysql.DB.Model(&model.User{}).Where("email = ?", "demo@example.com").Update("password", "123")
// 查
var row model.User
mysql.DB.Model(&model.User{}).Where("email = ?", "demo@example.com").First(&row)
fmt.Printf("row: %+v\n", row)
// 软删
mysql.DB.Where("email = ?", "demo@example.com").Delete(&model.User{})
// 硬删
mysql.DB.Unscoped().Where("email = ?", "demo@example.com").Delete(&model.User{})
}
|
执行db.go
1
2
|
cd ~/projects/gomall/demo/demo_proto/
go run cmd/dbop/db.go
|
编码指南
编码指南是一些经验丰富的开发者总结出来的一些可以帮助新手少走很多弯路,让开发更便捷的一些技巧或者规范
代码规范
Style Guide
Tools
代码格式化
在VSCode中配置
- 打开保存时自动格式化功能:在设置搜索
format on save
,勾选打开
- 选择具体的
link
工具:在设置搜索golint
,选择golangci-lint
- 选择具体的格式化工具:
gofumpt
在设置搜索gopls
,启用gofumpt
1
2
3
|
"gopls": {
"formatting.gofumpt": true
}
|
在设置搜索go format
,选择gofumpt
错误码设计
HTTP 响应状态码 - HTTP | MDN
HTTP 响应状态码用来表明特定 HTTP 请求是否成功完成。 响应被归为以下五大类:
- 信息响应 (
100
–199
):一些提示性的响应,比如websocket connection、Upgrade,成功就会返回101
- 成功响应 (
200
–299
):成功响应的一些状态码,比如200、202-成功,204-响应成功但是没有响应内容
- 重定向消息 (
300
–399
):表示一些重定向,常见的比如301、302、307、308,然后304也比较常见
- 客户端错误响应 (
400
–499
):表示一些客户端的请求错误,常见的比如400-参数错误、401-未认证、403-没有权限操作、404-not found,资源未找到
- 服务端错误响应 (
500
–599
):表示一些服务端的错误,常见的比如500,然后502、503、504表示一些网关错误、服务端错误、超时
在一个大型的系统中,会有非常多的自定义错误码,下面是设计一套自定义错误码的一些注意事项
错误码结构
一个错误码由四个部分组成:系统编码、业务编码、错误类型、接口/操作编码
- 系统编码:用于区分不同的子系统或者模块,如每个系统分配特定的两位或三位数字作为前缀
- 业务编码:反映具体业务领域或功能模块的错误,如交易商品管理、用户认证等
- 错误类型:描述具体的错误类型,如数据校验、错误资源未找到、权限不足等
- 接口/操作编码:描述复杂系统的不同接口或操作,精确指示哪个接口或操作发生了问题
错误码定义
每一个错误码都有其所对应的错误情境和原因,例如20001表示成功、40101表示用户名或密码错误、50000表示服务器内部错误
错误码文档
需要创建详细的错误码文档,包含每个错误码及其解释,以便团队成员和其他用户查询和理解
统一标准
统一团队内的错误码命名和使用规范,避免混淆。同时可以考虑业界通用标准,如HTTP错误代码,保持一定程度的协调一致
易读性和扩展性
错误码应该简洁且易于记忆,同时也要具备良好的扩展性。随着业务的发展和变化,能够方便地新增错误码,而不至于与现有错误码冲突
在实践中,还应当提供错误码的附加信息,如错误消息,以提供用户获得更详尽的错误描述。对于前后端分离的应用,通常会在响应体中返回一个固定字段,如code
来表示错误码,并配合message
字段来说明错误的具体内容。当code
等于特定值,如100000时,表示请求执行成功,其他值则表示不同类型的错误。
错误码的定义没有一个非常严格的规范,主要是系统各部分统一定义,方便后续沟通理解,看到错误码可以大概理解发生了什么错误,方便问题的定位
日志规范
Hertz适配了常见的日志库:hertz-contrib/logger: A collection of Hertz logger extension
结构化日志
常见的日志库可以很方便的打印结构化日志,如logrus、zap、slog、zerolog,可以通过添加key value
获取更多关键信息,方便后续的检索和分析
打印重要信息
打印信息一定要明确打印出具体的问题和原因,可以增加用户id
等关键信息。如果有配置链路追踪系统的话,一般会把traceid
跟spanid
打印进日志,最终可以通过链路跟日志串联起来,方便的去跟踪问题
分布式链路跟踪中的traceid和spanid代表什么?-CSDN博客
在分布式链路跟踪中有两个重要的概念:跟踪(trace)和 跨度( span)。trace 是请求在分布式系统中的整个链路视图,span 则代表整个链路中不同服务内部的视图,span 组合在一起就是整个 trace 的视图。
在整个请求的调用链中,请求会一直携带 traceid 往下游服务传递,每个服务内部也会生成自己的 spanid 用于生成自己的内部调用视图,并和traceid一起传递给下游服务。
traceid 在请求的整个调用链中始终保持不变,所以在日志中可以通过 traceid 查询到整个请求期间系统记录下来的所有日志。请求到达每个服务后,服务都会为请求生成spanid,而随请求一起从上游传过来的上游服务的 spanid 会被记录成parent-spanid或者叫 pspanid。当前服务生成的 spanid 随着请求一起再传到下游服务时,这个 spanid 又会被下游服务当做 pspanid 记录。
保证日志打印的稳定性
不能说业务处理失败,日志打印也失败,比如把日志输出到一个没有写权限的日志文件
注意
不要在日志中泄露敏感信息,比如密码和密钥。受法律保护的隐私数据包括邮箱、手机号地址等信息也是不允许做记录的,需要对这些数据做脱敏处理
提交规范
约定式提交
git提交信息一定要很清晰地表达出本次提交的主要内容,一般不要一次性提交太多代码,一次提交更多只是提交一个功能点,或者一个bug修复
关于提交规范有一个比较好的实践:约定式提交,定义提交信息包含几个部分
1
2
3
4
5
|
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
|
提交类型包括:
feat(功能)
:添加了新的功能或特性
- 示例:
git commit -m "feat: add search functionality"
fix(修复)
:修复了某个bug
- 示例:
git commit -m "fix: resolve null pointer exception"
chore(日常任务)
:完成了日常的维护任务,如更新依赖库、改进构建过程、工具配置等
- 示例:
git commit -m "chore: update dependencies to latest version"
docs(文档)
:更新了项目的文档,例如修改 README 文件、API 文档等
- 示例:
git commit -m "docs: update API documentation"
style(样式)
:代码样式的变更,如格式化代码、修正缩进、空格、空行等,例如修改代码结构、变量名、函数名等但没有影响代码逻辑
- 示例:
git commit -m "style: reformat code to follow coding standards"
refactor(重构)
:对代码进行了重构,优化了代码结构或清理了代码,但没有添加新功能或修复 bug
- 示例:
git commit -m "refactor: optimize data processing logic"
test(测试)
:添加或更新了测试用例
- 示例:
git commit -m "test: add unit tests for login module"
perf(性能)
:优化了代码的性能、减少内存占用等
- 示例:
git commit -m "perf: improve query performance by adding indexes"
build(构建)
:更改了构建系统或外部依赖项,例如修改依赖库、外部接口或者升级 Node 版本等
- 示例:
git commit -m "build: configure webpack for better code splitting"
ci(持续集成)
:更新了持续集成流程和脚本,例如修改 Travis、Jenkins 等工作流配置
- 示例:
git commit -m "ci: setup continuous deployment with Travis"
revert(回退)
:回退了之前的提交
- 示例:
git commit -m "revert: revert commit 0a12345"
版本命名
语义化版本 2.0.0 | Semantic Versioning
版本格式:主版本号.次版本号.修订号
,版本号递增规则如下:
- 主版本号:做了不兼容的 API 修改
- 次版本号:做了向下兼容的功能性新增
- 修订号:做了向下兼容的问题修正
先行版本号及版本编译信息可以加到主版本号.次版本号.修订号
的后面作为延伸
微服务通信
RPC
cloudwego社区提供了 RPC 框架 Kitex,支持 gRPC、thrift 等多种消息协议进行数据传输
- thrift 是一个开源的 RPC 框架,最初是 facebook,也就是现在的 meta 开发
- grpc 是谷歌开发的一个开源的 RPC 框架
demo1-rpc info
在前面的 demo 中使用 thrift
以及 protobuf
生成了两个 demo 项目,下面就用这两个项目来演示 RPC 通信
- 服务端获取
rpc info
:在服务端biz/service/echo.go
新增代码,获取客户端传来的service name
1
2
3
4
5
6
7
8
9
|
func (s *EchoService) Run(req *api.Request) (resp *api.Response, err error) {
// GetRPCInfo:kitex提供的一个从 context上下文中获取rpc info 的方法
info := rpcinfo.GetRPCInfo(s.ctx)
// 打印客户端传来的service name
fmt.Println(info.From().ServiceName())
return &api.Response{Message: req.Message}, nil
}
|
启动服务端
1
2
|
cd ~/projects/gomall/demo/demo_thrift
go run .
|
- 客户端发送
rpc info
:新建demo_thrift/cmd/client/client.go
编写客户端代码,两个配置设置为使用thrift
,并设置要传递的service name
信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
package main
import (
"context"
"fmt"
"gomall/demo/demo_thrift/kitex_gen/api"
"gomall/demo/demo_thrift/kitex_gen/api/echo"
"github.com/cloudwego/kitex/client"
"github.com/cloudwego/kitex/pkg/rpcinfo"
"github.com/cloudwego/kitex/pkg/transmeta"
"github.com/cloudwego/kitex/transport"
)
func main() {
cli, err := echo.NewClient("demo_thrift", client.WithHostPorts("localhost:8888"),
client.WithMetaHandler(transmeta.ClientTTHeaderHandler),
client.WithTransportProtocol(transport.TTHeader),
client.WithClientBasicInfo(&rpcinfo.EndpointBasicInfo{
ServiceName: "demo_thrift_client",
}),
)
if err != nil {
panic(err)
}
res, err := cli.Echo(context.Background(), &api.Request{
Message: "hello",
})
if err != nil {
fmt.Println("call failed:", err)
}
fmt.Printf("%v", res)
}
|
启动客户端,完成一次简单的RPC调用,可以看到服务端收到并打印了客户端传来的service name
1
2
|
cd ~/projects/gomall/demo/demo_thrift
go run cmd/client/client.go
|
kitex
不仅支持rpc info
,还可以通过metainfo
包来实现元信息的正向或反向传递。需要注意的是kitex grpc
需要满足CGI
网关风格的key
,也就是key
需要用大写加下划线
这种格式。kitex grpc
也兼容了原本的metadata
的传输方式
下面在demo_proto
演示使用metainfo
包来传递元数据
- 服务端获取
metainfo
:在服务端biz/service/echo.go
新增代码,获取客户端传来的CLIENT_NAME
1
2
3
4
5
6
7
8
9
10
|
func (s *EchoService) Run(req *pbapi.Request) (resp *pbapi.Response, err error) {
clientName, ok := metainfo.GetPersistentValue(s.ctx, "CLIENT_NAME")
fmt.Println(clientName, ok)
if req.Message == "error" {
return nil, kerrors.NewGRPCBizStatusError(1004001, "client param error")
}
return &pbapi.Response{Message: req.Message}, nil
}
|
启动依赖:mysql、服务注册中心
1
2
|
cd ~/projects/gomall
docker compose up
|
启动服务端
1
2
|
cd ~/projects/gomall/demo/demo_proto
go run .
|
- 客户端发送
metainfo
:在demo_thrift/cmd/client/client.go
编写客户端代码,用metainfo
构造一个带元信息的上下文,两个配置设置为使用thrift
,并设置要传递的service name
信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
func main() {
r, err := consul.NewConsulResolver(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
panic(err)
}
c, err := echo.NewClient("demo_proto", client.WithResolver(r),
client.WithTransportProtocol(transport.GRPC),
client.WithMetaHandler(transmeta.ClientHTTP2Handler),
)
if err != nil {
panic(err)
}
// 用 metainfo 构造一个带元信息的上下文
ctx := metainfo.WithPersistentValue(context.Background(), "CLIENT_NAME", "demo_proto_client")
res, err := c.Echo(ctx, &pbapi.Request{Message: "hello"})
if err != nil {
klog.Fatal(err)
}
fmt.Printf("%v", res)
}
|
启动客户端,可以看到服务端收到并打印了客户端传来的CLIENT_NAME
1
2
|
cd ~/projects/gomall/demo/demo_proto
go run cmd/client/client.go
|
demo3-kerrors
错误的传递:kitex
的kerrors
包提供了非常方便的创建错误的一些方法,常见的错误一种是环境异常,比如网络异常等,第二种就是业务异常,比如说参数错误。下面演示一个业务异常
- 服务端识别错误请求:修改服务端
echo.go
,如果请求信息等于error
,就返回一个业务异常
1
2
3
4
5
6
7
8
9
10
|
func (s *EchoService) Run(req *pbapi.Request) (resp *pbapi.Response, err error) {
clientName, ok := metainfo.GetPersistentValue(s.ctx, "CLIENT_NAME")
fmt.Println(clientName, ok)
if req.Message == "error" {
return nil, kerrors.NewGRPCBizStatusError(1004001, "client param error")
}
return &pbapi.Response{Message: req.Message}, nil
}
|
启动服务端
1
2
|
cd ~/projects/gomall/demo/demo_proto
go run .
|
- 客户端发送错误信息并接受错误信息的响应:修改客户端
client.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
func main() {
r, err := consul.NewConsulResolver(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
panic(err)
}
c, err := echo.NewClient("demo_proto", client.WithResolver(r),
client.WithTransportProtocol(transport.GRPC),
client.WithMetaHandler(transmeta.ClientHTTP2Handler),
)
if err != nil {
panic(err)
}
// 用 metainfo 构造一个带元信息的上下文
ctx := metainfo.WithPersistentValue(context.Background(), "CLIENT_NAME", "demo_proto_client")
res, err := c.Echo(ctx, &pbapi.Request{Message: "error"})
var bizErr *kerrors.GRPCBizStatusError
if err != nil {
ok := errors.As(err, &bizErr)
if ok {
fmt.Printf("%#v", bizErr)
}
klog.Fatal(err)
}
fmt.Printf("%v", res)
}
|
demo4-middleware
kitex
的client
和server
端都支持配置中间件,而且kitex
已经提供了很多开箱即用的中间件:kitex-contrib,常见的中间件比如prometheus
、opentelemetry
这些都有提供具体的用法。
中间件其实就是一些方法,方便我们在请求前或者请求后执行一些逻辑,常见的比如权限校验
下面演示自定义中间件的用法:打印具体逻辑的执行时间
- 编写中间件代码
demo_proto/middleware/middleware.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
package middleware
import (
"context"
"fmt"
"time"
"github.com/cloudwego/kitex/pkg/endpoint"
)
func Middleware(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, req, resp interface{}) (err error) {
begin := time.Now()
err = next(ctx, req, resp)
fmt.Println(time.Since(begin))
return err
}
}
|
- 将中间件应用到服务端:
demo_proto/main.go
1
2
3
4
5
6
7
8
9
|
func kitexInit() (opts []server.Option) {
// address
addr, err := net.ResolveTCPAddr("tcp", conf.GetConf().Kitex.Address)
if err != nil {
panic(err)
}
opts = append(opts, server.WithServiceAddr(addr), server.WithMiddleware(middleware.Middleware))
...
}
|
重启服务端
1
2
|
cd ~/projects/gomall/demo/demo_proto
go run .
|
- 将中间件应用到客户端:
client.go
1
2
3
4
5
|
c, err := echo.NewClient("demo_proto", client.WithResolver(r),
client.WithTransportProtocol(transport.GRPC),
client.WithMetaHandler(transmeta.ClientHTTP2Handler),
client.WithMiddleware(middleware.Middleware),
)
|
启动客户端,可以看到客户端打印了请求的执行时间,服务端打印了逻辑处理的执行时间
1
2
|
cd ~/projects/gomall/demo/demo_proto
go run cmd/client/client.go
|
RESTful API
主要用在HTTP
参考hertz-examples
参考中的请求用法:hertz-examples/client/send_request/main.go at main · cloudwego/hertz-examples
hertz
也支持自定义中间件,client
通过used
方法就可以应用一个中间件
hertz
也提供了很多开箱即用的中间件:hertz-contrib
消息中间件
消息中间件有很多优点
- 解耦服务:通过异步通信机制,消除服务间直接依赖,促进服务独立部署、升级与扩展
- 负载均衡与流量控制:
- 作为缓冲区吸收并暂存高峰期消息
- 协调系统应对突发流量,实现负载均衡
- 消费者按需拉取,避免下游服务因瞬时压力过大而崩溃
- 保障数据一致性与可靠性:提供事务消息、消息确认从事机制以及死信队列等功能,确保消息可靠投递和业务处理一致性,部分支持分布式事务处理框架
- 提升系统弹性与容错性:通过消息持久化、重复检测和故障恢复机制,增强系统对个别组件故障的容忍度,确保持续运行
- 支持服务水平扩展与弹性伸缩:集中处理消息,便于生产者和消费者根据负载动态水平扩展,适应变化的工作负载
后面的项目中会开发一个邮件服做消息中间件
NAME |
Language |
RabbitMQ |
Erlang |
Kafka |
Java |
RocketMQ |
Java |
Pulsar |
Java |
NATS |
Go |
Gomall
源码:biz-demo/gomall/tutorial at main · cloudwego/biz-demo
Gomall是一个学习 CloudWeGo 的教学项目,主要针对电商业务场景,基础架构如下
开发环境参考上一章开头的环境配置,代码位置:~/projects/gomall
前端页面
前端服务是整个项目的入口,是面向C端用户的系统
- 调用
RPC
服务:组装的数据会调用相应的RPC
服务
- 前后端不分离:由于本项目只关注后端的技术栈,前端页面几乎没有用到
JS
,所以前端直接选择用 Hertz 框架,把页面数据放在服务端生成,不做前后端的分离
UI
组件库:Bootstrap,在HTML
标签里添加相关的属性即可实现比较好看的样式
- 图形库:Font Awesome,有比较多的免费图标,基本能满足项目需求
- 页面骨架:
go template
编码思路
- 编写
IDL
,使用hz
生成基于IDL
的 Hertz
项目的脚手架,参考:hz 使用 (protobuf) | CloudWeGo
新建gomall/idl/api.proto
,api.proto
是 hz
提供的注解文件
1
2
3
4
|
// 注意此处改成3,其余代码参考官网即可
syntax = "proto3";
...
|
新建gomall/idl/frontend/home.proto
:放置网站首页的代码结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
syntax = "proto3";
package frontend.home;
option go_package = "frontend/home";
import "api.proto";
message Empty {}
// service名字后面会用做生成的`biz/handler/home/home_service`
service HomeService {
rpc Home(Empty) returns(Empty) {
option (api.get) = "/";
}
}
|
新建gomall/app/frontend
,存储前端代码
使用cwgo
生成代码
1
2
3
4
5
|
# cwgo 命令会在当前目录下生成代码
cd ~/projects/gomall/app/frontend
# -I :指定 IDL 搜索路径
# module:指定 go mod 名
cwgo server --type HTTP --idl ../../idl/frontend/home.proto --service frontend -module gomall/app/frontend -I ../../idl
|
- 配置前端首页访问,参考:渲染 | CloudWeGo
新建gomall/app/frontend/template/home.tmpl
在main.go
配置加载模板文件
1
2
3
|
router.GeneratedRegister(h)
// 加载模板文件
h.LoadHTMLGlob("template/*")
|
改造handler/home/home_service
:渲染home.tmpl
文件
1
2
|
// utils.SendSuccessResponse(ctx, c, consts.StatusOK, resp)
c.HTML(consts.StatusOK, "home.tmpl", resp)
|
启动项目
1
2
3
|
cd ~/projects/gomall/app/frontend
go mod tidy
go run .
|
- 引入热加载工具air:方便调试
1
2
3
4
5
6
7
8
9
|
go install github.com/air-verse/air@latest
cd ~/projects/gomall/app/frontend
# 优先在当前路径查找 `.air.toml` 后缀的文件,如果没有找到,则使用默认的
air -c .air.toml
# 在这之后,只需执行 air 命令,无需额外参数,它就能使用 .air.toml 文件中的配置了。
air
|
- 引入
UI
库:bootstrap
在官网下载编译好的Bootstrap
代码解压缩
- 将
dist/css
目录下的bootstrap.min.css
引入项目frontend/static/css
- 将
dist/js
目录下的bootstrap.bundle.min.js
引入项目frontend/static/js
参考官网示例,在home.tmpl
中使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bootstrap demo</title>
<link href="/static/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<h1>Hello, world!</h1>
<script src="/static/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
</body>
</html>
|
在main.go
中配置加载静态目录
1
2
3
4
5
|
// 加载模板文件
h.LoadHTMLGlob("template/*")
// 加载静态目录
h.Static("/static", "./")
|
- 编写首页代码
原型图制作:FigJam: The Online Collaborative Whiteboard for Teams
导航栏、图标、模板渲染
header.tmpl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
{{define "header"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{$.Title}} | Gomall</title>
<link href="/static/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<script src="https://kit.fontawesome.com/ca4befac0f.js" crossorigin="anonymous"></script>
</head>
<body>
<header>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<img class="navbar-brand" href="#" src="/static/img/logo.png" alt="gomall" style="height: 2.5em;"></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
aria-expanded="false">
Categories
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">T-shirt</a></li>
<li><a class="dropdown-item" href="#">Sticker</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link">About</a>
</li>
</ul>
<form class="d-flex" role="search">
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
<button class="btn btn-outline-success" type="submit">Search</button>
</form>
<div class="ms-3">
<i class="fa-solid fa-cart-shopping fa-xl"></i>
</div>
<div class="ms-3">
<i class="fa-solid fa-user fa-xl"></i>
</div>
<div class="ms-3">
<button type="button" class="btn btn-primary">Sign in</button>
</div>
</div>
</div>
</nav>
</header>
<main>
<div class="container-fluid">
{{end}}
|
home.tmpl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
{{template "header" .}}
<div class="row">
{{range .Items}}
<div class="card col-xl-3 col-xl-4 col-md-6 col-sm-12 p-5 border-0">
<img src="{{.Picture}}" class="card-img-top" alt="...">
<div class="card-body">
<p class="card-text">{{.Name}}</p>
<h5 class="card-title">{{.Price}}</h5>
</div>
</div>
{{end}}
</div>
{{template "footer" .}}
|
footer.tmpl
1
2
3
4
5
6
7
8
9
10
11
12
|
{{define "footer"}}
</div>
</main>
<footer>
<p class="text-center py-5">© 2025 Gomall </a> </p>
</footer>
<script src="/static/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
</body>
</html>
{{end}}
|
service/home.go
编写商品数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
func (h *HomeService) Run(req *home.Empty) (map[string]any, error) {
//defer func() {
// hlog.CtxInfof(h.Context, "req = %+v", req)
// hlog.CtxInfof(h.Context, "resp = %+v", resp)
//}()
resp := make(map[string]any)
items := []map[string]any{
{"Name": "T-shirt1", "Price": 100, "Picture": "/static/img/t-shirt.jpg"},
{"Name": "T-shirt2", "Price": 200, "Picture": "/static/img/t-shirt.jpg"},
{"Name": "T-shirt3", "Price": 300, "Picture": "/static/img/t-shirt.jpg"},
}
resp["Title"] = "Hot Sales"
resp["Items"] = items
return resp, nil
}
|
handler/home/home_service
删除resp
定义
1
2
|
// var resp *home.Empty
resp, err := service.NewHomeService(ctx, c).Run(&req)
|
由于学习目标主要是后端,所以后面不再写前端代码,后端服务直接通过api
调用
用户服务
中间件概览 | CloudWeGo
前端内容
用于管理session
的hertz
中间件:hertz-contrib/sessions: Sessions middleware for Hertz
用于管理jwt
的hertz
中间件:hertz-contrib/jwt: JWT middleware for Hertz
用于管理paseto
的hertz
中间件:hertz-contrib/paseto: Paseto middleware for Hertz.
paseto
是安全无状态令牌的规范和参考实现,鉴于jwt
家族过于自由,容易出现漏洞和不完全算法的使用下,paseto
提出了新的更加安全的令牌方案,更加注重安全隐私、易用性和多语言跨平台的兼容性
后端内容
idl
- 编写用户服务的
idl
:新增idl/user.proto
,定义用户注册和登录两个RPC方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
syntax = "proto3";
package user;
option go_package = "/user";
message RegisterReq {
string email = 1;
string password = 2;
string password_confirm = 3;
}
message RegisterResp {
int32 user_id = 1;
}
message LoginReq {
string email = 1;
string password = 2;
}
message LoginResp {
int32 user_id = 1;
}
service UserService {
rpc Register(RegisterReq) returns(RegisterResp) {}
rpc Login(LoginReq) returns(LoginResp) {}
}
|
- 根据
idl
生成服务端代码;(根据idl
生成客户端代码此处略过,原因见上,下文同理)
各个服务的kitex_gen
代码要生成到一个目录里,或者生成后移动到一个统一的gomall/rpc_gen
文件夹,修改包路径,避免后续引入包的冲突问题
1
2
3
4
|
cd ~/projects/gomall
mkdir -p app/user
cd app/user
cwgo server --type RPC --service user --module gomall/app/user -I ../../idl --idl user.proto
|
tips
--pass
:cwgo
提供的一个选项,将后续命令传递给底层生成代码的工具——kitex
kitex
有一个-use
的参数可以控制生成服务端代码时不生成客户端代码,直接使用指定模块
- 这么做的目的是服务端和客户端代码分离
配置连接
- 配置数据库连接:
biz/dal/mysql/init.go
1
2
3
4
5
6
7
8
9
10
11
12
13
|
func Init() {
dsn := fmt.Sprintf(conf.GetConf().MySQL.DSN, os.Getenv("MYSQL_USER"), os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_HOST"), os.Getenv("MYSQL_DATABASE"))
DB, err = gorm.Open(mysql.Open(dsn),
&gorm.Config{
PrepareStmt: true,
SkipDefaultTransaction: true,
},
)
err := DB.AutoMigrate(&model.User{})
if err != nil {
panic(err)
}
}
|
conf/test/conf.yaml
、conf/online/conf.yaml
、conf/dev/conf.yaml
1
2
|
mysql:
dsn: "%s:%s@tcp(%s:3306)/%s?charset=utf8mb4&parseTime=True&loc=Local"
|
user/.env
1
2
3
4
|
MYSQL_USER=root
MYSQL_PASSWORD=wyatt123
MYSQL_HOST=localhost
MYSQL_DATABASE=user_service
|
main.go
1
2
3
4
5
6
7
8
9
10
11
12
|
func main() {
// 引入godotenv加载环境配置文件
err := godotenv.Load()
if err != nil {
klog.Error(err.Error())
}
// 初始化数据库连接
dal.Init()
...
}
|
配置docker
1
2
3
4
5
6
7
8
9
10
|
mysql:
# 实际使用时最好锁定版本号, latest不是一个最佳实践
image: "mysql:latest"
ports:
- 3306:3306
environment:
# mysql镜像要求必须设置密码
- MYSQL_ROOT_PASSWORD=wyatt123
# 容器启动时初始化一个数据库user_service
- MYSQL_DATABASE=user_service
|
- 配置服务注册连接
1
2
3
|
# 下载`kitex`配置注册中心的`sdk`
cd ~/projects/gomall/app/user
go get github.com/kitex-contrib/registry-consul
|
服务端配置注册中心ip
:在main.go
中配置注册中心,参考官网-newconsulregister
1
2
3
4
5
6
7
8
9
10
11
12
|
consul "github.com/kitex-contrib/registry-consul"
// 初始化console注册中心组件 用户服务通过`ip`找到注册中心去注册本服务
r, err := consul.NewConsulRegister(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
log.Fatal(err)
}
// 设置服务启动参数
opts = append(opts, server.WithRegistry(r))
// klog
|
修改服务端环境配置文件:在配置文件conf/test/conf.yaml
中修改注册中心端口为8500
,配置当前服务端口
1
2
3
4
5
6
7
8
9
|
kitex:
service: "user"
address: ":8881"
...
registry:
registry_address:
- 127.0.0.1:8500
username: ""
password: ""
|
配置注册中心的docker
:gomall/docker-compose.yaml
1
2
3
4
5
6
7
|
services:
consul:
# demo用的consul:1.15.4
image: 'hashicorp/consul'
ports:
- 8500:8500
...
|
数据模型
- 定义用户数据模型:
user/biz/model/user.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
package model
import "gorm.io/gorm"
type User struct {
gorm.Model
Email string `gorm:"uniqueIndex;type:varchar(255) not null"`
Password string `gorm:"type:varchar(255) not null"`
}
func (User) TableName() string {
return "user"
}
func Create(db *gorm.DB, user *User) error {
return db.Create(user).Error
}
func GetByEmail(db *gorm.DB, email string) (*User, error) {
var user User
err := db.Where("email = ?", email).First(&user).Error
return &user, err
}
|
业务逻辑-注册
1
2
|
# 下载密码库 用于加密密码
go get golang.org/x/crypto
|
biz/service/register.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
func (s *RegisterService) Run(req *user.RegisterReq) (resp *user.RegisterResp, err error) {
// Finish your business logic.
if req.Email == "" || req.Password == "" || req.PasswordConfirm == "" {
// 返回错误
return nil, errors.New("邮箱或密码不能为空")
}
if req.Password != req.PasswordConfirm {
// 返回错误
return nil, errors.New("两次密码不一致")
}
// hash password
passwordHashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// 创建用户
newUser := &model.User{
Email: req.Email,
Password: string(passwordHashed),
}
// 写入数据库
err = model.Create(mysql.DB, newUser)
if err != nil {
return nil, err
}
return &user.RegisterResp{UserId: string(rune(newUser.ID))}, nil
}
|
单元测试register_test.go
,注意修改mysql.Init()
为绝对路径
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
package service
import (
"context"
"testing"
"gomall/app/user/biz/dal/mysql"
user "gomall/app/user/kitex_gen/user"
"github.com/joho/godotenv"
)
func TestRegister_Run(t *testing.T) {
// init env
godotenv.Load("../../.env")
// init db
mysql.Init()
ctx := context.Background()
s := NewRegisterService(ctx)
// init req and assert value
req := &user.RegisterReq{
Email: "demo1@mail.com",
Password: "123456",
PasswordConfirm: "123456",
}
resp, err := s.Run(req)
t.Logf("err: %v", err)
t.Logf("resp: %v", resp)
// todo: edit your unit test
}
|
业务逻辑-登录
login.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
func (s *LoginService) Run(req *user.LoginReq) (resp *user.LoginResp, err error) {
// Finish your business logic.
// 判断邮箱和密码是否为空
if req.Email == "" || req.Password == "" {
// 返回错误
return nil, errors.New("邮箱或密码不能为空")
}
// 查询用户
row, err := model.GetByEmail(mysql.DB, req.Email)
if err != nil {
return nil, err
}
// 验证密码
err = bcrypt.CompareHashAndPassword([]byte(row.Password), []byte(req.Password))
if err != nil {
return nil, err
}
return &user.LoginResp{
UserId: string(rune(row.ID)),
}, nil
}
|
login_test.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
func TestLogin_Run(t *testing.T) {
// init env
godotenv.Load("../../.env")
// init db
mysql.Init()
ctx := context.Background()
s := NewLoginService(ctx)
// init req and assert value
req := &user.LoginReq{
Email: "demo1@mail.com",
Password: "123456",
}
resp, err := s.Run(req)
t.Logf("err: %v", err)
t.Logf("resp: %v", resp)
// todo: edit your unit test
}
|
启动服务
- 启动
docker
容器:consul
、mysql
、redis
1
2
|
cd ~/projects/gomall
docker compose up
|
Consul提供了一个WEB界面可以用来查看所有的节点,通过8500
端口访问WEB管理界面
- 启动用户服务
1
2
3
4
5
6
7
8
9
10
|
cd ~/projects/gomall/app/user
go mod tidy
# 初始化过工作区的话此处不必重新初始化
# ---
cd ~/projects/gomall
go work init
cd ~/projects/gomall/app/user
# ---
go work use .
go run .
|
商品服务
idl
product.proto
:定义列出产品、获取单个产品和产品搜索三个RPC方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
syntax = "proto3";
package product;
option go_package ="/product";
service ProductCatalogService {
// 列出产品
rpc ListProducts(ListProductsReq) returns (ListProductsResp) {} ;
// 根据产品 ID 获取单个产品
rpc GetProduct(GetProductReq) returns (GetProductResp) {};
// 根据查询字符串进行产品搜索
rpc SearchProducts (SearchProductsReq) returns (SearchProductsResp) {} ;
}
message ListProductsReq {
int32 page = 1;
int32 page_size = 2;
string category_name = 3;
}
message Product {
uint32 id = 1;
string name = 2;
string description = 3;
string picture = 4;
float price = 5;
// repeated 关键字表示该字段可以包含多个 Product 消息
repeated string categories = 6;
}
message ListProductsResp {
repeated Product products = 1;
}
message GetProductReq {
uint32 id = 1;
}
message GetProductResp {
Product product = 1;
}
message SearchProductsReq {
string query = 1;
}
message SearchProductsResp {
repeated Product results = 1;
}
|
- 根据
idl
生成服务端代码;
各个服务的kitex_gen
代码要生成到一个目录里,或者生成后移动到一个统一的gomall/rpc_gen
文件夹,修改包路径,避免后续引入包的冲突问题
1
2
3
4
|
cd ~/projects/gomall
mkdir -p app/product
cd app/product
cwgo server --type RPC --service product --module gomall/app/product -I ../../idl --idl product.proto
|
配置连接
- 配置数据库连接:
biz/dal/mysql/init.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
func Init() {
dsn := fmt.Sprintf(conf.GetConf().MySQL.DSN, os.Getenv("MYSQL_USER"), os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_HOST"), os.Getenv("MYSQL_DATABASE"))
DB, err = gorm.Open(mysql.Open(dsn),
&gorm.Config{
PrepareStmt: true,
SkipDefaultTransaction: true,
},
)
if err != nil {
panic(err)
}
// 非线上环境下,初始化数据库表结构和数据
if os.Getenv("GO_ENV") != "online" {
needDemoData := !DB.Migrator().HasTable(&model.Product{})
DB.AutoMigrate( //nolint:errcheck
&model.Product{},
&model.Category{},
)
if needDemoData {
DB.Exec("INSERT INTO `product_service`.`category` VALUES (1,'2023-12-06 15:05:06','2023-12-06 15:05:06','T-Shirt','T-Shirt'),(2,'2023-12-06 15:05:06','2023-12-06 15:05:06','Sticker','Sticker')")
DB.Exec("INSERT INTO `product_service`.`product` VALUES ( 1, '2023-12-06 15:26:19', '2023-12-09 22:29:10', 'Notebook', 'The cloudwego notebook is a highly efficient and feature-rich notebook designed to meet all your note-taking needs. ', '/static/image/notebook.jpeg', 9.90 ), ( 2, '2023-12-06 15:26:19', '2023-12-09 22:29:10', 'Mouse-Pad', 'The cloudwego mouse pad is a premium-grade accessory designed to enhance your computer usage experience. ', '/static/image/mouse-pad.jpeg', 8.80 ), ( 3, '2023-12-06 15:26:19', '2023-12-09 22:31:20', 'T-Shirt', 'The cloudwego t-shirt is a stylish and comfortable clothing item that allows you to showcase your fashion sense while enjoying maximum comfort.', '/static/image/t-shirt.jpeg', 6.60 ), ( 4, '2023-12-06 15:26:19', '2023-12-09 22:31:20', 'T-Shirt', 'The cloudwego t-shirt is a stylish and comfortable clothing item that allows you to showcase your fashion sense while enjoying maximum comfort.', '/static/image/t-shirt-1.jpeg', 2.20 ), ( 5, '2023-12-06 15:26:19', '2023-12-09 22:32:35', 'Sweatshirt', 'The cloudwego Sweatshirt is a cozy and fashionable garment that provides warmth and style during colder weather.', '/static/image/sweatshirt.jpeg', 1.10 ), ( 6, '2023-12-06 15:26:19', '2023-12-09 22:31:20', 'T-Shirt', 'The cloudwego t-shirt is a stylish and comfortable clothing item that allows you to showcase your fashion sense while enjoying maximum comfort.', '/static/image/t-shirt-2.jpeg', 1.80 ), ( 7, '2023-12-06 15:26:19', '2023-12-09 22:31:20', 'mascot', 'The cloudwego mascot is a charming and captivating representation of the brand, designed to bring joy and a playful spirit to any environment.', '/static/image/logo.jpg', 4.80 )")
DB.Exec("INSERT INTO `product_service`.`product_category` (product_id,category_id) VALUES ( 1, 2 ), ( 2, 2 ), ( 3, 1 ), ( 4, 1 ), ( 5, 1 ), ( 6, 1 ),( 7, 2 )")
}
}
}
|
conf/test/conf.yaml
、conf/online/conf.yaml
、conf/dev/conf.yaml
1
2
|
mysql:
dsn: "%s:%s@tcp(%s:3306)/%s?charset=utf8mb4&parseTime=True&loc=Local"
|
product/.env
1
2
3
4
|
MYSQL_USER=root
MYSQL_PASSWORD=wyatt123
MYSQL_HOST=localhost
MYSQL_DATABASE=product_service
|
main.go
1
2
3
4
5
6
7
8
9
10
11
12
|
func main() {
// 引入godotenv加载环境配置文件
err := godotenv.Load()
if err != nil {
klog.Error(err.Error())
}
// 初始化数据库连接
dal.Init()
...
}
|
配置docker
1
2
3
4
5
6
7
8
9
10
|
mysql:
# 实际使用时最好锁定版本号, latest不是一个最佳实践
image: "mysql:latest"
ports:
- 3306:3306
environment:
# mysql镜像要求必须设置密码
- MYSQL_ROOT_PASSWORD=wyatt123
# 容器启动时初始化数据库
- MYSQL_DATABASE=product_service
|
参数只能初始化一个数据库,其他的需要手动创建
- 配置服务注册连接
1
2
3
|
# 下载`kitex`配置注册中心的`sdk`
cd ~/projects/gomall/app/product
go get github.com/kitex-contrib/registry-consul
|
服务端配置注册中心ip
:在main.go
中配置注册中心,参考官网-newconsulregister
1
2
3
4
5
6
7
8
9
10
11
12
|
consul "github.com/kitex-contrib/registry-consul"
// 初始化console注册中心组件 用户服务通过`ip`找到注册中心去注册本服务
r, err := consul.NewConsulRegister(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
log.Fatal(err)
}
// 设置服务启动参数
opts = append(opts, server.WithRegistry(r))
// klog
|
修改服务端环境配置文件:在配置文件conf/test/conf.yaml
中修改注册中心端口为8500
,配置当前服务端口
1
2
3
4
5
6
7
8
9
|
kitex:
service: "product"
address: ":8882"
...
registry:
registry_address:
- 127.0.0.1:8500
username: ""
password: ""
|
配置注册中心的docker
:gomall/docker-compose.yaml
1
2
3
4
5
6
7
|
services:
consul:
# demo用的consul:1.15.4
image: 'hashicorp/consul'
ports:
- 8500:8500
...
|
数据模型
- 定义基础数据模型:
biz/model/base.go
1
2
3
4
5
6
7
8
9
|
package model
import "time"
type Base struct {
ID int `gorm:"primarykey"`
CreatedAt time.Time
UpdatedAt time.Time
}
|
- 定义商品数据模型:
product.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
package model
import (
"context"
"gorm.io/gorm"
)
type Product struct {
Base
Name string `json:"name"`
Description string `json:"description"`
Picture string `json:"picture"`
Price float32 `json:"price"`
Categories []Category `json:"categories" gorm:"many2many:product_category"`
}
func (p Product) TableName() string {
return "product"
}
type ProductQuery struct {
ctx context.Context
db *gorm.DB
}
func (p ProductQuery) GetById(productId int) (product Product, err error) {
err = p.db.WithContext(p.ctx).Model(&Product{}).First(&product, productId).Error
return
}
func (p ProductQuery) SearchProducts(q string) (products []*Product, err error) {
err = p.db.WithContext(p.ctx).Model(&Product{}).Find(&products, "name like ? or description like ?",
"%"+q+"%", "%"+q+"%",
).Error
return
}
// 用于创建 ProductQuery 实例
func NewProductQuery(ctx context.Context, db *gorm.DB) *ProductQuery {
return &ProductQuery{
ctx: ctx,
db: db,
}
}
|
- 定义类别数据模型:
category.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
package model
import (
"context"
"gorm.io/gorm"
)
type Category struct {
Base
Name string `json:"name"`
Description string `json:"description"`
Products []Product `json:"product" gorm:"many2many:product_category"`
}
func (c Category) TableName() string {
return "category"
}
type CategoryQuery struct {
ctx context.Context
db *gorm.DB
}
func (c CategoryQuery) GetProductsByCategoryName(name string) (categories []Category, err error) {
err = c.db.WithContext(c.ctx).Model(&Category{}).Where(&Category{Name: name}).Preload("Products").Find(&categories).Error
return
}
// 用于创建 CategoryQuery 实例
func NewCategoryQuery(ctx context.Context, db *gorm.DB) *CategoryQuery {
return &CategoryQuery{
ctx: ctx,
db: db,
}
}
|
业务逻辑-获取
biz/service/get_product.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
func (s *GetProductService) Run(req *product.GetProductReq) (resp *product.GetProductResp, err error) {
if req.Id == 0 {
return nil, kerrors.NewGRPCBizStatusError(2004001, "product id is required")
}
productQuery := model.NewProductQuery(s.ctx, mysql.DB)
p, err := productQuery.GetById(int(req.Id))
if err != nil {
return nil, err
}
return &product.GetProductResp{
Product: &product.Product{
Id: uint32(p.ID),
Picture: p.Picture,
Price: p.Price,
Description: p.Description,
Name: p.Name,
},
}, nil
}
|
单元测试get_product.go
,注意修改mysql.Init()
为绝对路径
业务逻辑-列出
list_products.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
func (s *ListProductsService) Run(req *product.ListProductsReq) (resp *product.ListProductsResp, err error) {
// Finish your business logic.
categoryQuery := model.NewCategoryQuery(s.ctx, mysql.DB)
c, _ := categoryQuery.GetProductsByCategoryName(req.CategoryName)
resp = &product.ListProductsResp{}
for _, v1 := range c {
for _, v := range v1.Products {
resp.Products = append(resp.Products, &product.Product{
Id: uint32(v.ID),
Name: v.Name,
Description: v.Description,
Picture: v.Picture,
Price: v.Price,
})
}
}
return resp, nil
}
|
业务逻辑-查询
search_products.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
func (s *SearchProductsService) Run(req *product.SearchProductsReq) (resp *product.SearchProductsResp, err error) {
// Finish your business logic.
productQuery := model.NewProductQuery(s.ctx, mysql.DB)
products, err := productQuery.SearchProducts(req.Query)
var results []*product.Product
for _, v := range products {
results = append(results, &product.Product{
Id: uint32(v.ID),
Name: v.Name,
Description: v.Description,
Picture: v.Picture,
Price: v.Price,
})
}
return &product.SearchProductsResp{Results: results}, err
}
|
启动服务
- 启动
docker
容器:consul
、mysql
、redis
1
2
|
cd ~/projects/gomall
docker compose up
|
- 启动商品服务
1
2
3
4
|
cd ~/projects/gomall/app/product
go mod tidy
go work use .
go run .
|
购物车服务
idl
cart.proto
:定义列出产品、获取单个产品和产品搜索三个RPC方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
syntax = "proto3";
package cart;
option go_package = "/cart";
service CartService {
// 向添加购物车添加商品
rpc AddItem (AddItemReq) returns (AddItemResp) {}
// 获取购物车商品
rpc GetCart (GetCartReq) returns (GetCartResp) {}
// 清空购物车
rpc EmptyCart (EmptyCartReq) returns (EmptyCartResp) {}
}
message CartItem {
uint32 product_id = 1;
uint32 quantity = 2;
}
message AddItemReq {
uint32 user_id = 1;
CartItem item = 2;
}
message AddItemResp {}
message GetCartReq {
uint32 user_id = 1;
}
message GetCartResp {
repeated CartItem items = 1;
}
message EmptyCartReq {
uint32 user_id = 1;
}
message EmptyCartResp {}
|
- 根据
idl
生成服务端代码;
各个服务的kitex_gen
代码要生成到一个目录里,或者生成后移动到一个统一的gomall/rpc_gen
文件夹,修改包路径,避免后续引入包的冲突问题
1
2
3
4
|
cd ~/projects/gomall
mkdir -p app/cart
cd app/cart
cwgo server --type RPC --service cart --module gomall/app/cart -I ../../idl --idl cart.proto
|
配置连接
- 配置数据库连接:
biz/dal/mysql/init.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
func Init() {
dsn := fmt.Sprintf(conf.GetConf().MySQL.DSN, os.Getenv("MYSQL_USER"), os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_HOST"), os.Getenv("MYSQL_DATABASE"))
DB, err = gorm.Open(mysql.Open(dsn),
&gorm.Config{
PrepareStmt: true,
SkipDefaultTransaction: true,
},
)
if err != nil {
panic(err)
}
if os.Getenv("GO_ENV") != "online" {
err = DB.AutoMigrate(&model.Cart{})
if err != nil {
panic(err)
}
}
}
|
conf/test/conf.yaml
、conf/online/conf.yaml
、conf/dev/conf.yaml
1
2
|
mysql:
dsn: "%s:%s@tcp(%s:3306)/%s?charset=utf8mb4&parseTime=True&loc=Local"
|
cart/.env
1
2
3
4
|
MYSQL_USER=root
MYSQL_PASSWORD=wyatt123
MYSQL_HOST=localhost
MYSQL_DATABASE=cart_service
|
main.go
1
2
3
4
5
6
7
8
9
10
11
12
|
func main() {
// 引入godotenv加载环境配置文件
err := godotenv.Load()
if err != nil {
klog.Error(err.Error())
}
// 初始化数据库连接
dal.Init()
...
}
|
配置docker
1
2
3
4
5
6
7
8
9
10
|
mysql:
# 实际使用时最好锁定版本号, latest不是一个最佳实践
image: "mysql:latest"
ports:
- 3306:3306
environment:
# mysql镜像要求必须设置密码
- MYSQL_ROOT_PASSWORD=wyatt123
# 容器启动时初始化数据库
- MYSQL_DATABASE=cart_service
|
MYSQL_DATABASE
参数只能初始化一个数据库,其他的需要手动创建
- 配置服务注册连接
1
2
3
|
# 下载`kitex`配置注册中心的`sdk`
cd ~/projects/gomall/app/cart
go get github.com/kitex-contrib/registry-consul
|
服务端配置注册中心ip
:在main.go
中配置注册中心,参考官网-newconsulregister
1
2
3
4
5
6
7
8
9
10
11
12
|
consul "github.com/kitex-contrib/registry-consul"
// 初始化console注册中心组件 用户服务通过`ip`找到注册中心去注册本服务
r, err := consul.NewConsulRegister(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
log.Fatal(err)
}
// 设置服务启动参数
opts = append(opts, server.WithRegistry(r))
// klog
|
修改服务端环境配置文件:在配置文件conf/test/conf.yaml
中修改注册中心端口为8500
,配置当前服务端口
1
2
3
4
5
6
7
8
9
|
kitex:
service: "cart"
address: ":8883"
...
registry:
registry_address:
- 127.0.0.1:8500
username: ""
password: ""
|
配置注册中心的docker
:gomall/docker-compose.yaml
1
2
3
4
5
6
7
|
services:
consul:
# demo用的consul:1.15.4
image: 'hashicorp/consul'
ports:
- 8500:8500
...
|
数据模型
- 定义购物车相关数据模型:
biz/model/cart.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
type Cart struct {
gorm.Model
UserId uint32 `gorm:"type:int(11);not null;index:idx_user_id"`
ProductId uint32 `gorm:"type:int(11);not null;"`
Qty uint32 `gorm:"type:int(11);not null;"`
}
func (Cart) TableName() string {
return "cart"
}
func AddItem(ctx context.Context, db *gorm.DB, item *Cart) error {
var row Cart
// 查找购物车中是否已经存在该商品
err := db.WithContext(ctx).
Model(&Cart{}).
Where(&Cart{UserId: item.UserId, ProductId: item.ProductId}).
First(&row).Error
// 如果查询出错,并且错误不是 ErrRecordNotFound(即没有找到记录),则返回该错误
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
// 如果查询到了记录,则更新该记录的数量并返回
if row.ID > 0 {
return db.WithContext(ctx).
Model(&Cart{}).
Where(&Cart{UserId: item.UserId, ProductId: item.ProductId}).
UpdateColumn("qty", gorm.Expr("qty+?", item.Qty)).Error
}
// 如果没有查询到记录,则创建新的购物车商品条目
return db.WithContext(ctx).Create(item).Error
}
func EmptyCart(ctx context.Context, db *gorm.DB, userId uint32) error {
if userId == 0 {
return errors.New("user id is required")
}
return db.WithContext(ctx).Delete(&Cart{}, "user_id = ?", userId).Error
}
func GetCartByUserId(ctx context.Context, db *gorm.DB, userId uint32) ([]*Cart, error) {
var rows []*Cart
err := db.WithContext(ctx).
Model(&Cart{}).
Where(&Cart{UserId: userId}).
Find(&rows).Error
return rows, err
}
|
工具包
cart/utils/errors.go
:错误处理
1
2
3
4
5
6
7
8
9
|
package utils
import "github.com/cloudwego/kitex/pkg/klog"
func MustHandleError(err error) {
if err != nil {
klog.Fatal(err)
}
}
|
微服务间通信
购物车服务需要与商品服务通信
cart/rpc/product.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
var (
ProductClient productcatalogservice.Client
once sync.Once
)
// 在main函数中调用InitClient函数,初始化ProductClient
func InitClient() {
// 只执行一次初始化客户端操作
once.Do(func() {
initProductClient()
})
}
// 通过kitex创建一个与ProductService进行通信的RPC客户端
func initProductClient() {
// 存储客户端的选项配置
var opts []client.Option
// 通过获取的 Consul 注册中心地址创建一个解析器 r
r, err := consul.NewConsulResolver(conf.GetConf().Registry.RegistryAddress[0])
// 错误处理函数
cartutils.MustHandleError(err)
// 指定服务发现的解析器
opts = append(opts, client.WithResolver(r))
// 创建一个ProductClient客户端实例,连接到名为product的服务
ProductClient, err = productcatalogservice.NewClient("product", opts...)
// 错误处理函数
cartutils.MustHandleError(err)
}
|
main.go
:调用InitClient
函数,初始化一个与ProductService
进行通信的RPC客户端
1
2
3
4
5
6
|
func main() {
dal.Init()
// 初始化rpc客户端,用于连接其他服务
rpc.InitClient()
}
|
业务逻辑-添加
biz/service/add_item.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
func (s *AddItemService) Run(req *cart.AddItemReq) (resp *cart.AddItemResp, err error) {
// 通过 rpc.ProductClient.GetProduct 调用远程的 Product 服务
// 获取与商品ID (req.Item.ProductId) 相关的商品信息
// 使用 s.ctx 作为上下文,传递请求的生命周期信息(如超时、取消等)
productResp, err := rpc.ProductClient.GetProduct(s.ctx, &product.GetProductReq{Id: req.Item.ProductId})
if err != nil {
return nil, err
}
// 如果商品信息无效,则认为商品不存在
if productResp == nil || productResp.Product.Id == 0 {
return nil, kerrors.NewBizStatusError(40004, "product not found")
}
// 创建购物车条目
cartItem := &model.Cart{
UserId: req.UserId,
ProductId: req.Item.ProductId,
Qty: req.Item.Quantity,
}
// 持久化
err = model.AddItem(s.ctx, mysql.DB, cartItem)
if err != nil {
return nil, kerrors.NewBizStatusError(50000, err.Error())
}
return &cart.AddItemResp{}, nil
}
|
业务逻辑-清空
biz/service/empty_cart.go
1
2
3
4
5
6
7
8
|
func (s *EmptyCartService) Run(req *cart.EmptyCartReq) (resp *cart.EmptyCartResp, err error) {
// Finish your business logic.
err = model.EmptyCart(s.ctx, mysql.DB, req.UserId)
if err != nil {
return nil, kerrors.NewBizStatusError(50001, err.Error())
}
return &cart.EmptyCartResp{}, nil
}
|
业务逻辑-获取
biz/service/get_cart.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
func (s *GetCartService) Run(req *cart.GetCartReq) (resp *cart.GetCartResp, err error) {
list, err := model.GetCartByUserId(s.ctx, mysql.DB, req.UserId)
if err != nil {
return nil, kerrors.NewBizStatusError(50002, err.Error())
}
var items []*cart.CartItem
for _, item := range list {
items = append(items, &cart.CartItem{
ProductId: item.ProductId,
Quantity: item.Qty,
})
}
return &cart.GetCartResp{Items: items}, nil
}
|
启动服务
- 启动
docker
容器:consul
、mysql
、redis
1
2
|
cd ~/projects/gomall
docker compose up
|
- 启动购物车服务
1
2
3
4
|
cd ~/projects/gomall/app/cart
go mod tidy
go work use .
go run .
|
订单服务
idl
order.proto
:定义订单的RPC方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
syntax = "proto3";
package order;
import "cart.proto";
option go_package = "/order";
service OrderService {
rpc PlaceOrder (PlaceOrderReq) returns (PlaceOrderResp) {}
rpc ListOrder (ListOrderReq) returns (ListOrderResp) {}
}
message Address {
string street_address = 1;
string city = 2;
string state = 3;
string country = 4;
string zip_code = 5;
}
message OrderItem {
cart.CartItem item = 1;
float cost = 2;
}
message PlaceOrderReq {
uint32 user_id = 1;
Address address = 3;
string email = 4;
repeated OrderItem items = 5;
}
message OrderResult {
string order_id = 1;
}
message PlaceOrderResp {
OrderResult order = 1;
}
message ListOrderReq {
uint32 user_id = 1;
}
message Order {
repeated OrderItem items = 1;
string order_id = 2;
uint32 user_id = 3;
Address address = 4;
string email = 5;
int32 created_at = 6;
}
message ListOrderResp {
repeated Order orders = 1;
}
|
- 根据
idl
生成服务端代码;
各个服务的kitex_gen
代码要生成到一个目录里,或者生成后移动到一个统一的gomall/rpc_gen
文件夹,修改包路径,避免后续引入包的冲突问题
1
2
3
4
|
cd ~/projects/gomall
mkdir -p app/order
cd app/order
cwgo server --type RPC --service order --module gomall/app/order -I ../../idl --idl order.proto
|
配置连接
- 配置数据库连接:
biz/dal/mysql/init.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
func Init() {
dsn := fmt.Sprintf(conf.GetConf().MySQL.DSN, os.Getenv("MYSQL_USER"), os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_HOST"), os.Getenv("MYSQL_DATABASE"))
DB, err = gorm.Open(mysql.Open(dsn),
&gorm.Config{
PrepareStmt: true,
SkipDefaultTransaction: true,
},
)
if err != nil {
panic(err)
}
if os.Getenv("GO_ENV") != "online" {
err = DB.AutoMigrate(&model.Order{}, &model.OrderItem{})
if err != nil {
panic(err)
}
}
}
|
conf/test/conf.yaml
、conf/online/conf.yaml
、conf/dev/conf.yaml
1
2
|
mysql:
dsn: "%s:%s@tcp(%s:3306)/%s?charset=utf8mb4&parseTime=True&loc=Local"
|
payment/.env
1
2
3
4
|
MYSQL_USER=root
MYSQL_PASSWORD=wyatt123
MYSQL_HOST=localhost
MYSQL_DATABASE=order_service
|
main.go
1
2
3
4
5
6
7
8
9
10
11
12
|
func main() {
// 引入godotenv加载环境配置文件
err := godotenv.Load()
if err != nil {
klog.Error(err.Error())
}
// 初始化数据库连接
dal.Init()
...
}
|
配置docker
1
2
3
4
5
6
7
8
9
10
|
mysql:
# 实际使用时最好锁定版本号, latest不是一个最佳实践
image: "mysql:latest"
ports:
- 3306:3306
environment:
# mysql镜像要求必须设置密码
- MYSQL_ROOT_PASSWORD=wyatt123
# 容器启动时初始化数据库
- MYSQL_DATABASE=order_service
|
MYSQL_DATABASE
参数只能初始化一个数据库,其他的需要手动创建
- 配置服务注册连接
1
2
3
|
# 下载`kitex`配置注册中心的`sdk`
cd ~/projects/gomall/app/order
go get github.com/kitex-contrib/registry-consul
|
服务端配置注册中心ip
:在main.go
中配置注册中心,参考官网-newconsulregister
1
2
3
4
5
6
7
8
9
10
11
12
|
consul "github.com/kitex-contrib/registry-consul"
// 初始化console注册中心组件 用户服务通过`ip`找到注册中心去注册本服务
r, err := consul.NewConsulRegister(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
log.Fatal(err)
}
// 设置服务启动参数
opts = append(opts, server.WithRegistry(r))
// klog
|
修改服务端环境配置文件:在配置文件conf/test/conf.yaml
中修改注册中心端口为8500
,配置当前服务端口
1
2
3
4
5
6
7
8
9
|
kitex:
service: "order"
address: ":8884"
...
registry:
registry_address:
- 127.0.0.1:8500
username: ""
password: ""
|
配置注册中心的docker
:gomall/docker-compose.yaml
1
2
3
4
5
6
7
|
services:
consul:
# demo用的consul:1.15.4
image: 'hashicorp/consul'
ports:
- 8500:8500
...
|
数据模型
- 定义订单数据模型:
biz/model/order_item.go
1
2
3
4
5
6
7
8
9
10
11
12
|
type OrderItem struct {
gorm.Model
ProductId uint32 `gorm:"type:int(11)"`
// 建立与 Order 表的外键关系
OrderIdRefer string `gorm:"type:varchar(100);index"`
Quantity uint32 `gorm:"type:int(11)"`
Cost float32 `gorm:"type:decimal(10,2)"`
}
func (OrderItem) TableName() string {
return "order_item"
}
|
- 定义订单商品关联模型:
order.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// 收货人信息
type Consignee struct {
Email string
StreetAddress string
City string
State string
Country string
ZipCode string
}
type Order struct {
gorm.Model
OrderId string `gorm:"type:varchar(100);uniqueIndex"`
UserId uint32 `gorm:"type:int(11)"`
// Consignee 结构体中的字段会被直接嵌入到 Order 表中,而不是作为外部关联
Consignee Consignee `gorm:"embedded"`
// OrderIdRefer 是 OrderItem 中的字段,用于引用 Order 表中的 OrderId 字段。这个关系意味着每个订单可以有多个订单项
OrderItems []OrderItem `gorm:"foreignKey:OrderIdRefer;references:OrderId"`
}
func (Order) TableName() string {
return "order"
}
func ListOrder(ctx context.Context, db *gorm.DB, userId uint32) ([]*Order, error) {
var orders []*Order
err := db.WithContext(ctx).Where("user_id = ?", userId).Preload("OrderItems").Find(&orders).Error
if err != nil {
return nil, err
}
return orders, nil
}
|
业务逻辑-下单
biz/service/place_order.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
func (s *PlaceOrderService) Run(req *order.PlaceOrderReq) (resp *order.PlaceOrderResp, err error) {
if len(req.Items) == 0 {
err = kerrors.NewBizStatusError(500001, "items is empty")
return
}
// 开启事务 - 保证订单和订单项的一致性 双表操作
err = mysql.DB.Transaction(func(tx *gorm.DB) error {
orderId, _ := uuid.NewUUID()
o := &model.Order{
OrderId: orderId.String(),
UserId: req.UserId,
Consignee: model.Consignee{
Email: req.Email,
},
}
if req.Address != nil {
a := req.Address
o.Consignee.StreetAddress = a.StreetAddress
o.Consignee.City = a.City
o.Consignee.State = a.State
o.Consignee.Country = a.Country
}
// 创建订单
if err := tx.Create(o).Error; err != nil {
return err
}
var items []model.OrderItem
for _, v := range req.Items {
items = append(items, model.OrderItem{
OrderIdRefer: orderId.String(),
ProductId: v.Item.ProductId,
Quantity: v.Item.Quantity,
Cost: v.Cost,
})
}
// 创建订单项
if err := tx.Create(items).Error; err != nil {
return err
}
resp = &order.PlaceOrderResp{
Order: &order.OrderResult{
OrderId: orderId.String(),
},
}
return nil
})
return
}
|
业务逻辑-列出
biz/service/list_order.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
func (s *ListOrderService) Run(req *order.ListOrderReq) (resp *order.ListOrderResp, err error) {
list, err := model.ListOrder(s.ctx, mysql.DB, req.UserId)
if err != nil {
return nil, kerrors.NewBizStatusError(500001, err.Error())
}
var orders []*order.Order
for _, v := range list {
var items []*order.OrderItem
for _, oi := range v.OrderItems {
items = append(items, &order.OrderItem{
Item: &cart.CartItem{
ProductId: oi.ProductId,
Quantity: oi.Quantity,
},
Cost: oi.Cost,
})
}
orders = append(orders, &order.Order{
CreatedAt: int32(v.CreatedAt.Unix()),
OrderId: v.OrderId,
UserId: v.UserId,
Email: v.Consignee.Email,
Address: &order.Address{
StreetAddress: v.Consignee.StreetAddress,
Country: v.Consignee.Country,
City: v.Consignee.City,
State: v.Consignee.State,
ZipCode: v.Consignee.ZipCode,
},
Items: items,
})
}
resp = &order.ListOrderResp{
Orders: orders,
}
return
}
|
启动服务
- 启动
docker
容器:consul
、mysql
、redis
1
2
|
cd ~/projects/gomall
docker compose up
|
- 启动订单服务
1
2
3
4
|
cd ~/projects/gomall/app/order
go mod tidy
go work use .
go run .
|
支付服务
idl
payment.proto
:定义支付的RPC方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
syntax = "proto3";
package payment;
option go_package ="/payment";
service PaymentService {
rpc Charge(ChargeReq) returns (ChargeResp) {}
}
message CreditCardInfo {
string credit_card_number = 1;
int32 credit_card_cvv = 2;
int32 credit_card_expiration_year = 3;
int32 credit_card_expiration_month = 4;
}
message ChargeReq {
float amount = 1;
CreditCardInfo credit_card = 2;
string order_id = 3;
uint32 user_id = 4;
}
message ChargeResp {
string transaction_id = 1;
}
|
- 根据
idl
生成服务端代码;
各个服务的kitex_gen
代码要生成到一个目录里,或者生成后移动到一个统一的gomall/rpc_gen
文件夹,修改包路径,避免后续引入包的冲突问题
1
2
3
4
|
cd ~/projects/gomall
mkdir -p app/payment
cd app/payment
cwgo server --type RPC --service payment --module gomall/app/payment -I ../../idl --idl payment.proto
|
配置连接
- 配置数据库连接:
biz/dal/mysql/init.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
func Init() {
dsn := fmt.Sprintf(conf.GetConf().MySQL.DSN, os.Getenv("MYSQL_USER"), os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_HOST"), os.Getenv("MYSQL_DATABASE"))
DB, err = gorm.Open(mysql.Open(dsn),
&gorm.Config{
PrepareStmt: true,
SkipDefaultTransaction: true,
},
)
if err != nil {
panic(err)
}
if os.Getenv("GO_ENV") != "online" {
err = DB.AutoMigrate(&model.Cart{})
if err != nil {
panic(err)
}
}
}
|
conf/test/conf.yaml
、conf/online/conf.yaml
、conf/dev/conf.yaml
1
2
|
mysql:
dsn: "%s:%s@tcp(%s:3306)/%s?charset=utf8mb4&parseTime=True&loc=Local"
|
payment/.env
1
2
3
4
|
MYSQL_USER=root
MYSQL_PASSWORD=wyatt123
MYSQL_HOST=localhost
MYSQL_DATABASE=payment_service
|
main.go
1
2
3
4
5
6
7
8
9
10
11
12
|
func main() {
// 引入godotenv加载环境配置文件
err := godotenv.Load()
if err != nil {
klog.Error(err.Error())
}
// 初始化数据库连接
dal.Init()
...
}
|
配置docker
1
2
3
4
5
6
7
8
9
10
|
mysql:
# 实际使用时最好锁定版本号, latest不是一个最佳实践
image: "mysql:latest"
ports:
- 3306:3306
environment:
# mysql镜像要求必须设置密码
- MYSQL_ROOT_PASSWORD=wyatt123
# 容器启动时初始化数据库
- MYSQL_DATABASE=payment_service
|
MYSQL_DATABASE
参数只能初始化一个数据库,其他的需要手动创建
- 配置服务注册连接
1
2
3
|
# 下载`kitex`配置注册中心的`sdk`
cd ~/projects/gomall/app/payment
go get github.com/kitex-contrib/registry-consul
|
服务端配置注册中心ip
:在main.go
中配置注册中心,参考官网-newconsulregister
1
2
3
4
5
6
7
8
9
10
11
12
|
consul "github.com/kitex-contrib/registry-consul"
// 初始化console注册中心组件 用户服务通过`ip`找到注册中心去注册本服务
r, err := consul.NewConsulRegister(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
log.Fatal(err)
}
// 设置服务启动参数
opts = append(opts, server.WithRegistry(r))
// klog
|
修改服务端环境配置文件:在配置文件conf/test/conf.yaml
中修改注册中心端口为8500
,配置当前服务端口
1
2
3
4
5
6
7
8
9
|
kitex:
service: "payment"
address: ":8885"
...
registry:
registry_address:
- 127.0.0.1:8500
username: ""
password: ""
|
配置注册中心的docker
:gomall/docker-compose.yaml
1
2
3
4
5
6
7
|
services:
consul:
# demo用的consul:1.15.4
image: 'hashicorp/consul'
ports:
- 8500:8500
...
|
第三方包
验证信用卡有效性
1
2
|
cd ~/projects/gomall/app/payment
go get github.com/durango/go-credit-card
|
数据模型
- 定义基础数据模型:
biz/model/base.go
1
2
3
4
5
|
type Base struct {
ID int `gorm:"primarykey"`
CreatedAt time.Time
UpdatedAt time.Time
}
|
- 支付相关数据模型:
payment.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
type PaymentLog struct {
Base
UserId uint32 `json:"user_id"`
OrderId string `json:"order_id"`
TransactionId string `json:"transaction_id"`
Amount float32 `json:"amount"`
PayAt time.Time `json:"pay_at"`
}
func (p PaymentLog) TableName() string {
return "payment"
}
func CreatePaymentLog(db *gorm.DB, ctx context.Context, payment *PaymentLog) error {
return db.WithContext(ctx).Model(&PaymentLog{}).Create(payment).Error
}
|
业务逻辑-支付
biz/service/charge.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
func (s *ChargeService) Run(req *payment.ChargeReq) (resp *payment.ChargeResp, err error) {
// 创建 card 对象
card := creditcard.Card{
Number: req.CreditCard.CreditCardNumber, // 信用卡号
Cvv: strconv.Itoa(int(req.CreditCard.CreditCardCvv)), // 信用卡背面的三位数字
Month: strconv.Itoa(int(req.CreditCard.CreditCardExpirationMonth)), // 信用卡有效期月份
Year: strconv.Itoa(int(req.CreditCard.CreditCardExpirationYear)), // 信用卡有效期年份
}
// 验证信用卡信息
err = card.Validate(true)
if err != nil {
return nil, kerrors.NewBizStatusError(400, err.Error())
}
// 生成交易 ID
transactionId, err := uuid.NewRandom()
if err != nil {
return nil, err
}
// 创建支付记录并持久化
err = model.CreatePaymentLog(mysql.DB, s.ctx, &model.PaymentLog{
UserId: req.UserId,
OrderId: req.OrderId,
TransactionId: transactionId.String(),
Amount: req.Amount,
PayAt: time.Now(),
})
if err != nil {
return nil, err
}
return &payment.ChargeResp{TransactionId: transactionId.String()}, nil
}
|
启动服务
- 启动
docker
容器:consul
、mysql
、redis
1
2
|
cd ~/projects/gomall
docker compose up
|
- 启动支付服务
1
2
3
4
|
cd ~/projects/gomall/app/payment
go mod tidy
go work use .
go run .
|
结算服务
idl
checkout.proto
:定义结算的RPC方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
syntax = "proto3";
package checkout;
import "payment.proto";
option go_package = "/checkout";
service CheckoutService {
rpc Checkout(CheckoutReq) returns (CheckoutResp) {}
}
message CheckoutReq {
uint32 user_id = 1;
string firstname = 2;
string lastname = 3;
string email =4;
Address address = 5;
payment.CreditCardInfo credit_card = 6;
}
message Address {
string street_address = 1;
string city = 2;
string state = 3;
string country = 4;
string zip_code = 5;
}
message CheckoutResp {
string order_id = 1;
string transaction_id = 2;
}
|
- 根据
idl
生成服务端代码;
各个服务的kitex_gen
代码要生成到一个目录里,或者生成后移动到一个统一的gomall/rpc_gen
文件夹,修改包路径,避免后续引入包的冲突问题
1
2
3
4
|
cd ~/projects/gomall
mkdir -p app/checkout
cd app/checkout
cwgo server --type RPC --service checkout --module gomall/app/checkout -I ../../idl --idl checkout.proto
|
配置连接
- 配置数据库连接:
biz/dal/mysql/init.go
1
2
3
4
5
6
7
8
9
10
11
12
|
func Init() {
dsn := fmt.Sprintf(conf.GetConf().MySQL.DSN, os.Getenv("MYSQL_USER"), os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_HOST"), os.Getenv("MYSQL_DATABASE"))
DB, err = gorm.Open(mysql.Open(dsn),
&gorm.Config{
PrepareStmt: true,
SkipDefaultTransaction: true,
},
)
if err != nil {
panic(err)
}
}
|
conf/test/conf.yaml
、conf/online/conf.yaml
、conf/dev/conf.yaml
1
2
|
mysql:
dsn: "%s:%s@tcp(%s:3306)/%s?charset=utf8mb4&parseTime=True&loc=Local"
|
checkout/.env
1
2
3
4
|
MYSQL_USER=root
MYSQL_PASSWORD=wyatt123
MYSQL_HOST=localhost
MYSQL_DATABASE=checkout_service
|
main.go
1
2
3
4
5
6
7
8
9
10
11
12
|
func main() {
// 引入godotenv加载环境配置文件
err := godotenv.Load()
if err != nil {
klog.Error(err.Error())
}
// 初始化数据库连接
dal.Init()
...
}
|
配置docker
1
2
3
4
5
6
7
8
9
10
|
mysql:
# 实际使用时最好锁定版本号, latest不是一个最佳实践
image: "mysql:latest"
ports:
- 3306:3306
environment:
# mysql镜像要求必须设置密码
- MYSQL_ROOT_PASSWORD=wyatt123
# 容器启动时初始化数据库
- MYSQL_DATABASE=checkout_service
|
MYSQL_DATABASE
参数只能初始化一个数据库,其他的需要手动创建
- 配置服务注册连接
1
2
3
|
# 下载`kitex`配置注册中心的`sdk`
cd ~/projects/gomall/app/checkout
go get github.com/kitex-contrib/registry-consul
|
服务端配置注册中心ip
:在main.go
中配置注册中心,参考官网-newconsulregister
1
2
3
4
5
6
7
8
9
10
11
12
|
consul "github.com/kitex-contrib/registry-consul"
// 初始化console注册中心组件 用户服务通过`ip`找到注册中心去注册本服务
r, err := consul.NewConsulRegister(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
log.Fatal(err)
}
// 设置服务启动参数
opts = append(opts, server.WithRegistry(r))
// klog
|
修改服务端环境配置文件:在配置文件conf/test/conf.yaml
中修改注册中心端口为8500
,配置当前服务端口
1
2
3
4
5
6
7
8
9
|
kitex:
service: "checkout"
address: ":8886"
...
registry:
registry_address:
- 127.0.0.1:8500
username: ""
password: ""
|
配置注册中心的docker
:gomall/docker-compose.yaml
1
2
3
4
5
6
7
|
services:
consul:
# demo用的consul:1.15.4
image: 'hashicorp/consul'
ports:
- 8500:8500
...
|
微服务间通信
- 结算服务会先调用购物车服务,拿到购物车中的商品信息,然后计算总价,再调用支付服务,创建未支付订单,若支付成功,则标记支付订单成功
- 调用购物车、商品、支付和订单四个服务:
checkout/infra/rpc/client.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
|
var (
CartClient cartservice.Client
ProductClient productcatalogservice.Client
PaymentClient paymentservice.Client
OrderClient orderservice.Client
once sync.Once
err error
)
func InitClient() {
// 初始化不同服务的客户端实例
once.Do(func() {
initCartClient()
initProductClient()
initPaymentClient()
initOrderClient()
})
}
func initCartClient() {
var opts []client.Option
r, err := consul.NewConsulResolver(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
panic(err)
}
opts = append(opts, client.WithResolver(r))
opts = append(opts,
client.WithClientBasicInfo(&rpcinfo.EndpointBasicInfo{ServiceName: conf.GetConf().Kitex.Service}),
client.WithTransportProtocol(transport.GRPC),
client.WithMetaHandler(transmeta.ClientHTTP2Handler),
)
CartClient, err = cartservice.NewClient("cart", opts...)
if err != nil {
panic(err)
}
}
func initProductClient() {
var opts []client.Option
r, err := consul.NewConsulResolver(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
panic(err)
}
opts = append(opts, client.WithResolver(r))
opts = append(opts,
client.WithClientBasicInfo(&rpcinfo.EndpointBasicInfo{ServiceName: conf.GetConf().Kitex.Service}),
client.WithTransportProtocol(transport.GRPC),
client.WithMetaHandler(transmeta.ClientHTTP2Handler),
)
ProductClient, err = productcatalogservice.NewClient("product", opts...)
if err != nil {
panic(err)
}
}
func initPaymentClient() {
var opts []client.Option
r, err := consul.NewConsulResolver(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
panic(err)
}
opts = append(opts, client.WithResolver(r))
opts = append(opts,
client.WithClientBasicInfo(&rpcinfo.EndpointBasicInfo{ServiceName: conf.GetConf().Kitex.Service}),
client.WithTransportProtocol(transport.GRPC),
client.WithMetaHandler(transmeta.ClientHTTP2Handler),
)
PaymentClient, err = paymentservice.NewClient("payment", opts...)
if err != nil {
panic(err)
}
}
func initOrderClient() {
var opts []client.Option
r, err := consul.NewConsulResolver(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
panic(err)
}
opts = append(opts, client.WithResolver(r))
opts = append(opts,
client.WithClientBasicInfo(&rpcinfo.EndpointBasicInfo{ServiceName: conf.GetConf().Kitex.Service}),
client.WithTransportProtocol(transport.GRPC),
client.WithMetaHandler(transmeta.ClientHTTP2Handler),
)
OrderClient, err = orderservice.NewClient("order", opts...)
if err != nil {
panic(err)
}
}
|
main.go
:调用InitClient
函数,初始化一个与其他服务通信的RPC客户端
1
2
3
4
5
6
|
func main() {
dal.Init()
// 初始化rpc客户端,用于连接其他服务
rpc.InitClient()
}
|
业务逻辑-结算
biz/service/checkout.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
|
/*
Run
1. get cart
2. calculate cart
3. create order
4. empty cart
5. pay
6. change order result
7. finish
*/
func (s *CheckoutService) Run(req *checkout.CheckoutReq) (resp *checkout.CheckoutResp, err error) {
// Finish your business logic.
cartResult, err := rpc.CartClient.GetCart(s.ctx, &cart.GetCartReq{UserId: req.UserId})
if err != nil {
return nil, kerrors.NewGRPCBizStatusError(5005001, err.Error())
}
if cartResult == nil || cartResult.Items == nil {
return nil, kerrors.NewGRPCBizStatusError(5004001, "cart is empty")
}
var (
total float32
oi []*order.OrderItem
)
for _, cartItem := range cartResult.Items {
productResp, resultErr := rpc.ProductClient.GetProduct(s.ctx, &product.GetProductReq{
Id: cartItem.ProductId,
})
if resultErr != nil {
return nil, resultErr
}
if productResp.Product == nil {
continue
}
p := productResp.Product.Price
cost := p * float32(cartItem.Quantity)
total += cost
oi = append(oi, &order.OrderItem{
Item: &cart.CartItem{
ProductId: cartItem.ProductId,
Quantity: cartItem.Quantity,
},
Cost: cost,
})
}
var orderId string
orderResp, err := rpc.OrderClient.PlaceOrder(s.ctx, &order.PlaceOrderReq{
UserId: req.UserId,
Email: req.Email,
Address: &order.Address{
StreetAddress: req.Address.StreetAddress,
City: req.Address.City,
State: req.Address.State,
Country: req.Address.Country,
ZipCode: req.Address.ZipCode,
},
Items: oi,
})
if err != nil {
return nil, kerrors.NewGRPCBizStatusError(5004002, err.Error())
}
if orderResp != nil && orderResp.Order != nil {
orderId = orderResp.Order.OrderId
}
payReq := &payment.ChargeReq{
UserId: req.UserId,
OrderId: orderId,
Amount: total,
CreditCard: &payment.CreditCardInfo{
CreditCardNumber: req.CreditCard.CreditCardNumber,
CreditCardCvv: req.CreditCard.CreditCardCvv,
CreditCardExpirationMonth: req.CreditCard.CreditCardExpirationMonth,
CreditCardExpirationYear: req.CreditCard.CreditCardExpirationYear,
},
}
_, err = rpc.CartClient.EmptyCart(s.ctx, &cart.EmptyCartReq{UserId: req.UserId})
if err != nil {
klog.Error(err.Error())
}
paymentResult, err := rpc.PaymentClient.Charge(s.ctx, payReq)
if err != nil {
return nil, err
}
klog.Info(paymentResult)
resp = &checkout.CheckoutResp{
OrderId: orderId,
TransactionId: paymentResult.TransactionId,
}
return
}
|
启动服务
- 启动
docker
容器:consul
、mysql
、redis
1
2
|
cd ~/projects/gomall
docker compose up
|
- 启动结算服务
1
2
3
4
|
cd ~/projects/gomall/app/checkout
go mod tidy
go work use .
go run .
|
通知服务
idl
email.proto
:定义通知的RPC方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
syntax = "proto3";
package email;
option go_package = "/email";
message EmailReq{
string from = 1;
string to = 2;
string content_type = 3;
string subject = 4;
string content = 5;
}
message EmailResp {
}
service EmailService{
rpc Send(EmailReq) returns (EmailResp);
}
|
- 根据
idl
生成服务端代码;
各个服务的kitex_gen
代码要生成到一个目录里,或者生成后移动到一个统一的gomall/rpc_gen
文件夹,修改包路径,避免后续引入包的冲突问题
1
2
3
4
5
6
|
cd ~/projects/gomall
mkdir -p app/email
cd app/email
cwgo server --type RPC --service checkout --module gomall/app/email -I ../../idl --idl email.proto
# 用上面的 生成后移动到一个统一的gomall/rpc_gen文件夹,修改包路径,避免后续引入包的冲突问题
cwgo server --type RPC --service email --module gomall/app/email --pass "-use gomall/rpc_gen" -I ../../idl --idl ../../idl/email.proto
|
tips
--pass
:cwgo
提供的一个选项,将后续命令传递给底层生成代码的工具——kitex
kitex
有一个-use
的参数可以控制生成服务端代码时不生成客户端代码,直接使用指定模块
- 这么做的目的是服务端和客户端代码分离
配置连接
- 配置服务注册连接
1
2
3
|
# 下载`kitex`配置注册中心的`sdk`
cd ~/projects/gomall/app/email
go get github.com/kitex-contrib/registry-consul
|
服务端配置注册中心ip
:在main.go
中配置注册中心,参考官网-newconsulregister
1
2
3
4
5
6
7
8
9
10
11
12
|
consul "github.com/kitex-contrib/registry-consul"
// 初始化console注册中心组件 用户服务通过`ip`找到注册中心去注册本服务
r, err := consul.NewConsulRegister(conf.GetConf().Registry.RegistryAddress[0])
if err != nil {
log.Fatal(err)
}
// 设置服务启动参数
opts = append(opts, server.WithRegistry(r))
// klog
|
修改服务端环境配置文件:在配置文件conf/test/conf.yaml
中修改注册中心端口为8500
,配置当前服务端口
1
2
3
4
5
6
7
8
9
|
kitex:
service: "email"
address: ":8887"
...
registry:
registry_address:
- 127.0.0.1:8500
username: ""
password: ""
|
配置注册中心的docker
:gomall/docker-compose.yaml
1
2
3
4
5
6
7
|
services:
consul:
# demo用的consul:1.15.4
image: 'hashicorp/consul'
ports:
- 8500:8500
...
|
- 配置消息中间件
nats
配置nats
服务器的docker
镜像
1
2
3
4
5
|
nats:
image: "nats:latest"
ports:
- "4222:4222"
- "8222:8222"
|
下载nats
客户端的go
包
1
|
go get github.com/nats-io/nats.go@latest
|
连接到nat
服务器:email/infra/mq/nats.go
1
2
3
4
5
6
7
8
9
10
11
|
var (
Nc *nats.Conn
err error
)
func Init() {
Nc, err = nats.Connect(nats.DefaultURL)
if err != nil {
panic(err)
}
}
|
通知服务启动时连接到消息队列服务器:main.go
1
2
3
4
5
6
|
opts := kitexInit()
// 连接到消息队列服务器
mq.Init()
svr := emailservice.NewServer(new(EmailServiceImpl), opts...)
|
业务逻辑-消费
通知服务从消息队列拿到消息后发送邮件
- 发送邮件:
email/infra/notify/email.go
1
2
3
4
5
6
7
8
9
10
11
12
13
|
type NoopEmail struct{}
// 发送电子邮件
func (e *NoopEmail) Send(req *email.EmailReq) error {
// 打印传入的 req 参数的内容
pretty.Printf("%v\n", req)
return nil
}
// 构造函数,用于创建并返回一个 NoopEmail 类型的实例
func NewNoopEmail() NoopEmail {
return NoopEmail{}
}
|
- 消费者订阅主题、处理消息:发送邮件,
email/biz/consumer/email/email.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
func ConsumerInit() {
// 消费者连接到消息队列服务器:订阅 email 主题,处理该主题上接收到的消息
sub, err := mq.Nc.Subscribe("email", func(m *nats.Msg) {
// 反序列化消息
var req email.EmailReq
err := proto.Unmarshal(m.Data, &req)
if err != nil {
klog.Error(err)
}
// 使用 NoopEmail 模拟发送邮件
noopEmail := notify.NewNoopEmail()
_ = noopEmail.Send(&req)
})
if err != nil {
panic(err)
}
// 注册服务关闭钩子
server.RegisterShutdownHook(func() {
sub.Unsubscribe() //nolint:errcheck
mq.Nc.Close()
})
}
|
- 初始化所有消费者:
email/biz/consumer/consumer.go
1
2
3
|
func Init() {
email.ConsumerInit()
}
|
- 服务启动时连接到消息队列并初始化消费者:
main.go
1
2
3
4
5
6
7
|
opts := kitexInit()
// 连接到消息队列服务器并初始化消费者
mq.Init()
consumer.Init()
svr := emailservice.NewServer(new(EmailServiceImpl), opts...)
|
业务逻辑-生产
结算服务完成后向消息队列放入消息
- 下载
nats
客户端的go
包
1
|
go get github.com/nats-io/nats.go@latest
|
- 连接到
nat
服务器:checkout/infra/mq/nats.go
1
2
3
4
5
6
7
8
9
10
11
|
var (
Nc *nats.Conn
err error
)
func Init() {
Nc, err = nats.Connect(nats.DefaultURL)
if err != nil {
panic(err)
}
}
|
- 结算服务启动时连接到消息队列服务器:
main.go
1
2
3
4
5
6
|
opts := kitexInit()
// 连接到消息队列服务器
mq.Init()
svr := emailservice.NewServer(new(EmailServiceImpl), opts...)
|
- 生产者发布消息:
checkout/biz/service/checkout.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
...
data, _ := proto.Marshal(&email.EmailReq{
From: "from@example.com",
To: req.Email,
ContentType: "text/plain",
Subject: "You just created an order in CloudWeGo shop",
Content: "You just created an order in CloudWeGo shop",
})
// 创建消息对象
msg := &nats.Msg{Subject: "email", Data: data}
// 发布消息到消息队列
_ = mq.Nc.PublishMsg(msg)
klog.Info(paymentResult)
...
|
启动服务
- 启动
docker
容器:consul
、mysql
、redis
1
2
|
cd ~/projects/gomall
docker compose up
|
- 启动服务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
cd ~/projects/gomall/app/user
go run .
cd ~/projects/gomall/app/product
go run .
cd ~/projects/gomall/app/cart
go run .
cd ~/projects/gomall/app/order
go run .
cd ~/projects/gomall/app/payment
go run .
cd ~/projects/gomall/app/checkout
go run .
cd ~/projects/gomall/app/email
go run .
|
可观测性 - 指标
- 指标(Metrics):反映服务的运行状态,比如(某一个路径)成功/错误的请求数量
- 链路(Traces):定位到一个具体的请求,清晰的看到请求在应用间传播的过程,哪里有问题、哪里逻辑运行不正常
- 日志(Logs):记录更详细的细节
Prometheus - Monitoring system & time series database

- 服务器:
Prometheus server
负责指标的收集、存储以及告警信息的上报,定时去拉取应用以及其他组件产生的指标
- 存储:
TSDB
组件可以直接把指标存在本地磁盘上;也可以通过远程写入,把指标写到其他的存储,比如influxDB
- 服务发现:
Prometheus
也提供了很多服务发现的机制,比如文件、K8S
、consul
- 启动各个微服务应用时,将服务信息注册到
consul
里面,包括服务地址、端口路径,Prometheus server
通过服务发现的规则去consul
获取
- 服务数量比较少时,也可以通过
YAML
配置文件的方式去配置各个服务
- 可视化:当
server
获取到指标之后,就可以通过Grafana
连接到Prometheus
去做检索和面板的配置
- 监控告警:
Prometheus
也提供了监控告警的组件Alertmanager
:通过一些预定义的规则,如果有符合告警的规则,就会把告警内容推送到Alertmanager
,Alertmanager
负责把它通过邮件或者通讯工具等发送出去
- 收集指标:
Prometheus
推荐使用拉模式来收集指标,但是也提供了一种Pushgateway
来满足一些特定的场景
Pushgateway
主要的应用场景是定时任务、批处理等,这些服务通常是非常短暂的,他们将运行过程中产生的一些指标推送到Pushgateway
,最终Prometheus
从Pushgateway
来拉取指标
kitex
集成prometheus
:Github,其基于Prometheus Go SDK
基本配置
- 引入
Prometheus
客户端的SDK包
1
2
3
4
5
|
mkdir ~/projects/gomall/common
cd common
go mod init gomall/common
go work use .
go get github.com/kitex-contrib/monitor-prometheus
|
- 编写
prometheus
客户端的初始化函数:common/mtl/metrics.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
var Registry *prometheus.Registry
// 初始化 Prometheus 指标和服务注册
func InitMetric(serviceName string, metricsPort string, registryAddr string) {
// 创建并注册 Prometheus 指标
Registry = prometheus.NewRegistry()
// 注册了一个用于收集 Go 语言运行时指标(如内存、CPU 等)的收集器
Registry.MustRegister(collectors.NewGoCollector())
// 注册了一个收集当前进程相关指标的收集器
Registry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
// 创建一个新的 Consul 注册器
r, _ := consul.NewConsulRegister(registryAddr)
// 解析传入的 metricsPort,将其转换为 TCP 地址格式
addr, _ := net.ResolveTCPAddr("tcp", metricsPort)
// 注册信息
registryInfo := ®istry.Info{
ServiceName: "prometheus",
Addr: addr,
Weight: 1,
Tags: map[string]string{"service": serviceName},
}
// 将服务注册到 Consul 服务发现系统中
_ = r.Register(registryInfo)
// 注册关闭钩子 当服务关闭时执行注销操作
server.RegisterShutdownHook(func() {
r.Deregister(registryInfo) //nolint:errcheck
})
// 注册了一个 HTTP 路由 /metrics,当 Prometheus 访问该路由时,会基于 Registry 返回当前服务收集到的指标
http.Handle("/metrics", promhttp.HandlerFor(Registry, promhttp.HandlerOpts{}))
// 通过协程并发启动一个 HTTP 服务器,并监听传入的 metricsPort 端口
go http.ListenAndServe(metricsPort, nil) //nolint:errcheck
}
|
- 编写
prometheus
服务器的依赖配置:gomall/deploy/config/prometheus.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
global:
scrape_interval: 15s
scrape_configs:
- job_name: "consul"
consul_sd_configs:
- server: consul:8500
services:
- prometheus
relabel_configs:
- source_labels: [ __meta_consul_tags ]
action: replace
target_label: service
regex: ".*service:(.*?),.*"
replacement: "$1"
- source_labels: [ __meta_consul_service_id ]
target_label: __metrics_path__
replacement: /metrics
|
docker-compose
:用到上面的配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
prometheus:
image: prom/prometheus:latest
volumes:
- ./deploy/config/prometheus.yml:/etc/prometheus/prometheus.yml
command:
- "--config.file=/etc/prometheus/prometheus.yml"
ports:
- "9090:9090"
grafana:
image: grafana/grafana:latest
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_AUTH_DISABLE_LOGIN_FORM=true
ports:
- "3000:3000"
|
引入服务
在cart
服务中引入prometheus
客户端
- 在
cart
新增配置:cart/conf/conf.go
1
2
3
4
|
type Kitex struct {
...
MetricsPort string `yaml:"metrics_port"`
}
|
cart/conf/test/conf.yaml
、cart/conf/dev/conf.yaml
、cart/conf/online/conf.yaml
1
2
3
4
5
|
kitex:
...
address: ":8883"
metrics_port: ":9993"
...
|
- 改造
cart/main.go
:抽出配置中心代码到common/serversuite/server.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
type CommonServerSuite struct {
CurrentServiceName string
RegistryAddr string
}
// 配置服务器
func (s CommonServerSuite) Options() []server.Option {
opts := []server.Option{
server.WithMetaHandler(transmeta.ServerHTTP2Handler),
server.WithServerBasicInfo(&rpcinfo.EndpointBasicInfo{
ServiceName: s.CurrentServiceName,
}),
server.WithTracer(prometheus.NewServerTracer("",
"",
prometheus.WithDisableServer(true), prometheus.WithRegistry(mtl.Registry)),
),
}
// 初始化console注册中心组件 用户服务通过`ip`找到注册中心去注册本服务
r, err := consul.NewConsulRegister(s.RegistryAddr)
if err != nil {
log.Fatal(err)
}
opts = append(opts, server.WithRegistry(r))
return opts
}
|
- 修改
main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
|
var (
serviceName = conf.GetConf().Kitex.Service
RegistryAddr = conf.GetConf().Registry.RegistryAddress[0]
)
func main() {
// 引入godotenv加载环境配置文件
err := godotenv.Load()
if err != nil {
klog.Error(err.Error())
}
// 初始化监控
mtl.InitMetric(serviceName, conf.GetConf().Kitex.MetricsPort, RegistryAddr)
// 初始化数据库连接
dal.Init()
// 初始化rpc客户端,用于连接其他服务
rpc.InitClient()
// 初始化服务启动的选项 opts
opts := kitexInit()
// 创建服务
svr := cartservice.NewServer(new(CartServiceImpl), opts...)
// 启动 Kitex 服务器
err = svr.Run()
if err != nil {
klog.Error(err.Error())
}
}
func kitexInit() (opts []server.Option) {
// address
addr, err := net.ResolveTCPAddr("tcp", conf.GetConf().Kitex.Address)
if err != nil {
panic(err)
}
opts = append(opts, server.WithServiceAddr(addr), server.WithSuite(serversuite.CommonServerSuite{
CurrentServiceName: serviceName,
RegistryAddr: RegistryAddr,
}))
// klog
logger := kitexlogrus.NewLogger()
klog.SetLogger(logger)
klog.SetLevel(conf.LogLevel())
asyncWriter := &zapcore.BufferedWriteSyncer{
WS: zapcore.AddSync(&lumberjack.Logger{
Filename: conf.GetConf().Kitex.LogFileName,
MaxSize: conf.GetConf().Kitex.LogMaxSize,
MaxBackups: conf.GetConf().Kitex.LogMaxBackups,
MaxAge: conf.GetConf().Kitex.LogMaxAge,
}),
FlushInterval: time.Minute,
}
klog.SetOutput(asyncWriter)
server.RegisterShutdownHook(func() {
asyncWriter.Sync()
})
return
}
|
- 改造
cart/rpc/client.go
:抽出配置中心代码到common/clientsuite/client.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
type CommonGrpcClientSuite struct {
CurrentServiceName string
RegistryAddr string
}
func (s CommonGrpcClientSuite) Options() []client.Option {
opts := []client.Option{
client.WithClientBasicInfo(&rpcinfo.EndpointBasicInfo{
ServiceName: s.CurrentServiceName,
}),
client.WithMetaHandler(transmeta.ClientHTTP2Handler),
client.WithTransportProtocol(transport.GRPC),
}
// 通过获取的 Consul 注册中心地址创建一个解析器 r
r, err := consul.NewConsulResolver(s.RegistryAddr)
if err != nil {
panic(err)
}
opts = append(opts, client.WithResolver(r))
return opts
}
|
- 修改
cart/rpc/client.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
var (
ProductClient productcatalogservice.Client
once sync.Once
CurrentServiceName = conf.GetConf().Kitex.Service
RegistryAddr = conf.GetConf().Registry.RegistryAddress[0]
err error
)
// 在main函数中调用InitClient函数,初始化ProductClient
func InitClient() {
// 只执行一次初始化客户端操作
once.Do(func() {
initProductClient()
})
}
// 通过kitex创建一个与ProductService进行通信的RPC客户端
func initProductClient() {
// 客户端的选项配置
opts := []client.Option{
client.WithSuite(clientsuite.CommonGrpcClientSuite{
CurrentServiceName: CurrentServiceName,
RegistryAddr: RegistryAddr,
}),
}
// 创建一个consul解析器
r, err := consul.NewConsulResolver(RegistryAddr)
if err != nil {
panic(err)
}
// 将解析器添加到客户端选项中
opts = append(opts, client.WithResolver(r))
// 添加客户端基本信息
opts = append(opts,
client.WithClientBasicInfo(&rpcinfo.EndpointBasicInfo{ServiceName: CurrentServiceName}),
client.WithTransportProtocol(transport.GRPC),
client.WithMetaHandler(transmeta.ClientHTTP2Handler),
)
// 创建一个ProductClient客户端实例,连接到名为product的服务
ProductClient, err = productcatalogservice.NewClient("product", opts...)
// 错误处理函数
cartutils.MustHandleError(err)
}
|
- 将以上改动复制到其他服务,
metrics_port
最后一位看服务地址
- 检查各服务中的
common
是否用的本地common
模块
启动服务
- 启动
docker
和各个服务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
cd ~/projects/gomall/app/user
go run .
cd ~/projects/gomall/app/product
go run .
cd ~/projects/gomall/app/cart
go run .
cd ~/projects/gomall/app/order
go run .
cd ~/projects/gomall/app/payment
go run .
cd ~/projects/gomall/app/checkout
go run .
cd ~/projects/gomall/app/email
go run .
|
- 访问
localhost:8500
在注册中心可以看到有七个kitex
服务,以及prometheus
服务下有七个实例
- 访问
localhots:9993/metrics
查看有cart
服务的metrics
- 访问
localhost:3000
,在Grafana
中添加Data sources
,connetion
的prometheus server URL
添http://prometheus:9090
,Save & test
;复制biz-demo/gomall/deploy/grafana.json到Dashboards
右上角New->Import
,然后Data sources
选刚才添加的,Name
填kitex
,点击Import
,即可看到仪表盘可视化界面
可观测性 - 链路
OpenTelemetry是可观测性一个通用的解决方案,也算是CNCF可观测性领域的一个事实标准。GitHub

- 各种服务生成链路数据,数据上报到
collector
collector
主要包括三个部分:receiver
负责接收数据、processor
负责处理数据、exporter
负责导出数据
对于OpenTelemetry
,cloudwego
社区提供了相应的适配,方便生成链路数据及指标
基本配置
common/mtl/tracing.go
1
2
3
4
5
6
7
8
9
10
|
import "github.com/kitex-contrib/obs-opentelemetry/provider"
func InitTracing(serviceName string) provider.OtelProvider {
p := provider.NewOpenTelemetryProvider(
provider.WithServiceName(serviceName),
provider.WithInsecure(),
provider.WithEnableMetrics(false),
)
return p
}
|
common/serversuite/server.go
1
2
3
4
5
6
7
8
|
func (s CommonServerSuite) Options() []server.Option {
...
prometheus.WithDisableServer(true), prometheus.WithRegistry(mtl.Registry)),
),
server.WithSuite(tracing.NewServerSuite()),
}
...
}
|
common/clientsuite/client.go
1
2
3
4
5
6
7
|
func (s CommonGrpcClientSuite) Options() []client.Option {
...
client.WithTransportProtocol(transport.GRPC),
client.WithSuite(tracing.NewClientSuite()),
}
...
}
|
docker-compose
1
2
3
4
5
6
7
8
|
# Jaeger
jaeger-all-in-one:
container_name: jaeger-all-in-one
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686"
- "4317:4317"
- "4318:4318"
|
引入服务
cart/main.go
,其他服务的配置同理
1
2
3
4
5
6
7
8
9
10
11
12
|
func main() {
...
// 初始化监控
mtl.InitMetric(serviceName, conf.GetConf().Kitex.MetricsPort, RegistryAddr)
// 初始化链路追踪
p := mtl.InitTracing(serviceName)
// 关闭链路追踪前,确保所有链路追踪数据都已经发送
defer p.Shutdown(context.Background())
...
}
|
opentelemetry for gorm
配置gorm
的链路追踪:cart/biz/dal/mysql/init.go
,其他服务的gorm
配置同理
1
2
3
4
5
6
7
8
9
10
11
12
|
import "gorm.io/plugin/opentelemetry/tracing"
func Init() {
...
if err := DB.Use(tracing.NewPlugin(tracing.WithoutMetrics())); err != nil {
panic(err)
}
if os.Getenv("GO_ENV") != "online" {
...
}
|
配置消息中间件mq
的链路追踪:email/biz/consumer/email/email.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
func ConsumerInit() {
tracer := otel.Tracer("shop-nats-consumer")
...
ctx := context.Background()
ctx = otel.GetTextMapPropagator().Extract(ctx, propagation.HeaderCarrier(m.Header))
_, span := tracer.Start(ctx, "shop-nats-consumer")
defer span.End()
// 使用 NoopEmail 模拟发送邮件
...
...
}
|
checkout/biz/service/checkout.go
1
2
3
4
5
6
7
8
|
func (s *CheckoutService) Run(req *checkout.CheckoutReq) (resp *checkout.CheckoutResp, err error) {
...
// 创建消息对象
msg := &nats.Msg{Subject: "email", Data: data, Header: make(nats.Header)}
otel.GetTextMapPropagator().Inject(s.ctx, propagation.HeaderCarrier(msg.Header))
...
}
|
启动服务
- 启动
docker
和各个服务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
cd ~/projects/gomall/app/user
go run .
cd ~/projects/gomall/app/product
go run .
cd ~/projects/gomall/app/cart
go run .
cd ~/projects/gomall/app/order
go run .
cd ~/projects/gomall/app/payment
go run .
cd ~/projects/gomall/app/checkout
go run .
cd ~/projects/gomall/app/email
go run .
|
- 访问
localhost:16686
查看链路数据
可观测性-日志
日志管理方案
开源日志管理方案 ELK 和 EFK 的区别 - 腾讯云
ELK:Elasticsearch + Logstash + Kibana
EFK:Elasticsearch + Filebeat or Fluentd + Kibana

Filebeats
:收集本地 log
数据
Logstash
:收集分布在各处的 log
并进行处理
Elasticsearch
:集中存储 log
数据
Kibana
: Elasticsearch
开发的前端 GUI
,图形化方式查询和分析 Elasticsearch
中存储的数据
这套系统资源消耗较高,配置和维护也比较复杂
GLP
GLP:Grafana + Loki + Promtail
GLP
是一种新的日志管理系统,适合微服务和云原生环境

Promtail
:类似于Logstash
或者Filebeats
,负责日志数据的收集,更轻量、不依赖于JDK
环境
Loki
:是一个高度可扩展、成本效益高的日志聚合系统,专为云原生应用设计,跟es
不同,Loki
不执行全文索引,而是索引日志的元数据,从而减少存储需求和查询复杂性
Grafana
:是一个流行的可视化分析平台,不仅能够展示指标数据,也可以展示Loki
收集的日志,为用户提供统一的监控和日志查看体验
GLP
的优势在于其轻量化、资源效率高、易于部署和维护、成本也更低,缺点是全文搜索能力和高级知识分析不如ELK
配置环境
docker-compose
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
loki:
image: grafana/loki:2.9.2
volumes:
- ./deploy/config/loki.yml:/etc/loki/local-config.yaml
command: -config.file=/etc/loki/local-config.yaml
ports:
- "3100:3100"
promtail:
image: grafana/promtail:2.9.2
volumes:
- ./deploy/config/promtail.yml:/etc/promtail/config.yml
- ./app/frontend/log:/logs/frontend
command: -config.file=/etc/promtail/config.yml
|
deploy/config/loki.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
# config.yml
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
common:
instance_addr: 127.0.0.1
path_prefix: /tmp/loki
storage:
filesystem:
chunks_directory: /tmp/loki/chunks
rules_directory: /tmp/loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
query_range:
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 100
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v11
index:
prefix: index_
period: 24h
|
deploy/config/promtail.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
# config.yml
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: hertz
pipeline_stages:
- json:
expressions:
level: level
- labels:
level:
static_configs:
- targets:
- localhost
labels:
app: frontend
__path__: /logs/frontend/hertz.log
|
启动服务
启动服务
- 启动
docker
和各个服务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
cd ~/projects/gomall/app/user
go run .
cd ~/projects/gomall/app/product
go run .
cd ~/projects/gomall/app/cart
go run .
cd ~/projects/gomall/app/order
go run .
cd ~/projects/gomall/app/payment
go run .
cd ~/projects/gomall/app/checkout
go run .
cd ~/projects/gomall/app/email
go run .
|
- 访问
localhost:3000
,打开Grafana
,添加一个data source: loki
,connection
填http://loki:3100
,Save & test
下面部分只学习理论,暂未实操
容器化与部署
What is a Container? | Docker
CNCF Landscape
步骤:
- 编写
Dockerfile
生成各个服务的镜像
- 编写
docker-compose.yaml
启动各个服务容器
- 部署在
k8s
中:使用kind在本地部署一个k8s
集群
服务优化
缓存、微服务的稳定性、配置中心的使用
缓存
缓存:减少对底层速度较慢的存储层的访问需求,以此来提高数据检索性能
- 给商品服务增加
Redis
缓存:通过Id
查询商品,先查缓存,再查数据库,最后放到缓存
微服务的稳定性
微服务架构因其灵活性和可扩展性而广受欢迎,但同时带来了服务治理、监控和稳定性方面的挑战;确保微服务的高可用和稳定性,是构建可靠分布式系统的关键
Kitex
提供了常见的服务治理特性:服务注册、服务发现、配置中心、负载均衡以及超时控制、重试策略包括熔断、fall back
和限速
熔断器作用:在进行 RPC 调用时,下游服务难免会出错;当下游出现问题时,如果上游继续对其进行调用,既妨碍了下游的恢复,也浪费了上游的资源;为了解决这个问题,可以设置一些动态开关,当下游出错时,手动的关闭对下游的调用;然而更好的办法是使用熔断器,自动化的解决这个问题。
FallBack:业务在 RPC 请求失败后通常会有一些降级措施保证有效返回(比如请求超时、熔断后,构造默认返回),Kitex 的 Fallback 支持对所有异常请求进行处理。同时,因为业务异常通常会通过 Resp(BaseResp) 返回,所以也支持对 Resp 进行处理
- 在前端
rpc
调用商品服务中增加熔断策略和Fallback
:根据RPC的成功失败情况,限制对下游的访问,预设一个返回
配置中心
配置中心的用法
CICD
CI
:continuous integration
,持续集成,这种开发实践要求开发成员,频繁的将代码提交到版本库,并且每次提交都要自动化构建,进行编译和测试的验证,尽早发现集成的错误。集成工具要求快速反馈构建和测试的结果,帮助开发人员及早的发现和修复问题
CD
:持续交付或持续部署,这两个虽然有所不同,但它们都是自动化软件交付的一部分
- 持续交付通过自动化流程,确保随时可发布的状态,交付给用户。开发者可以选择性的进行发布,持续交付的目标是降低发布新版本的成本、风险和实践,并确保软件的高质量
- 持续部署是在持续交付的进一步演变,其核心理念是每次代码变更,都通过了自动化测试和构建,并且能够自动化发布到生产环境,没有人工干预。这种会要求强大的自动化测试和监控系统,以确保部署到生产环境的代码是稳定和可靠的
持续交付会侧重于将软件的每个版本都保持在一个可以随时交付的状态,但是部署是手动触发的
持续部署是进步了一个部署的过程,它使得每个通过测试的变更,都可以自动的部署到生产环境,但前提是通过了所有的自动化测试和审批流程
无论是持续交付还是持续部署,他们都强调通过自动化来提高软件开发和交付的效率、质量和可靠性,这些是现代DevOps
实践中的重要组成部分
CI/CD
的重要性
- 提高软件的质量,确保每次提交都经过严格验证
- 加速产品的迭代,缩短从代码提交到生产部署的时间
- 减少人工错误
- 提高团队的协作效率
CI/CD
的生态工具
GitHub Action
:集成于GitHub
GitLab CI
:GitLab
内置的CI/CD
解决方案
Jenkins
:开源的CI/CD
服务社区,java
Travis CI
:专注于持续集成,支持多种语言
Argo CD
:功能强大的开源工具,适合于 k8s
环境中实现持续交付
实践步骤
- 选择一个合适的
CI/CD
工具
- 设计
CI/CD Pipeline
:代码拉取 -> 构建与测试 -> 静态代码分析 -> 打包 -> 部署
CI/CD
高级特性:环境变量和密钥配置、并行构建与测试、自定义的脚本和命令
- 最佳实践:代码审查和分支策略、自动化测试的覆盖率、监控和报警、回滚策略
网站访问
域名+https+cdn
黑盒监控与告警
可观测性的一些延伸,同时也是应用上线后最后一些基础建设的补充
白盒监控:前边做的可观测性是通过内部数据和指标,来监控系统的性能和健康状态
黑盒监控:模拟用户的操作,不需要了解系统内部的实现细节,从外部观察系统的行为,评估系统的性能和可用性
Prometheus Blackbox Exporter

Prometheus
通过HTTP
请求,向Blackbox Exporter
发送探测的任务
Blackbox Exporter
根据配置的探测模块,对目标进行一个探测,把结果返回给Prometheus
Prometheus
定期的去抓取这些结果分析,然后存储在它的数据库中
Exporter
是Prometheus
监控体系的一个重要的组件或者架构组成,负责从各种系统和服务中收集指标数据,并暴露给Prometheus
。Exporter
也可以理解为特定的监控指标采集工具,由Prometheus
根据配置抓取解析进行存储