返回

CloudWeGo学习笔记

介绍

CloudWeGo 是由字节跳动推出的开源中间件集,可用于快速构建企业级云原生架构。CloudWeGo的特点是高性能、高可扩展性、高可靠性以及专注于微服务通信和治理。

cloudwego-arch.png (1280×1064)

基础知识

环境配置

  • GoUbuntu 中安装 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

  1. 编写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)
}
  1. 利用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
  1. 运行服务端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

  1. 编写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) {}
}
  1. 利用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
  1. 运行服务端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:分区容错性

在工程实践中,我们最多只能实现其二

  • AP:eureka,nacos

demo

思路:

  1. demo_proto为服务端,改造一些服务注册的代码
  2. 利用docker启动一个consul注册中心
  3. 编写一个客户端代码,发现demo_proto实例并进行接口调用

配置服务端的服务注册

Consul | CloudWeGo

  1. 下载kitex配置注册中心的sdk
1
2
cd ~/projects/gomall/demo/demo_proto
go get github.com/kitex-contrib/registry-consul
  1. 服务端配置注册中心:在demo_protomain.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: ""
  1. 使用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容器

1
docker compose up -d

Consul提供了一个WEB界面可以用来查看所有的节点,通过8500端口访问WEB管理界面

  1. 启动服务端
1
2
cd ~/projects/gomall/demo/demo_proto
go run .

编写客户端来发现服务并调用

  1. 编写客户端代码
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)
}
  1. 运行客户端
1
go run .

配置管理

介绍

常见的配置有以下三种

  1. 文件配置:将配置写在文件中,文件格式一般是yamljsontoml
    • cwgo脚手架里默认已经提供了一套配置文件,用的是yaml格式
    • demo_proto/conf目录下,conf.go负责解析所有的配置文件,三个文件夹对应开发、生成和测试三个环境的配置,默认生效的是测试环境的配置
  2. 环境变量:通过环境变量来注入配置
  3. 配置中心:选择一个配置中心来存储所有的配置,修改配置之后可以即时生效。我们的应用会连接到配置中心,配置中心会提供比如watch的方式去监听某些配置,然后实时的把配置中心的更改推送到应用
    • kitex提供了各种配置中心的适配,常见的配置中心有EtcdConsulZookeeperNacosApollo
    • k8s的底层存储元数据都是使用的etcdtecdcncf(云原生计算基金会)的毕业项目,非常流行,稳定性有保障
    • 配置中心的选择跟上一节的注册中心是类似的,很多情况下,配置中心跟注册中心都会选同一个,这样使用起来比较方便,维护成本也更低
    • java开发的配置中心,如ZookeeperNacos也是比较流行的
    • Kitex提供的几种配置中心集成的仓库

demo

下面以配置连接mysql为例

  1. 修改配置文件:在test环境下conf.yaml中,将mysqldsn字段(数据源名称)的mysql usermysql passwordmysql hostmysql 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"
  1. 设置配置文件的读取:在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),
		...
	)
	...
}
  1. 通过.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
  1. 加载.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.gomain函数开头调用

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()
}
  1. 配置mysqldocker容器:在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
  1. 启动mysql容器
1
2
cd ~/projects/gomall
docker compose up
  1. 启动服务端
1
2
3
cd ~/projects/gomall/demo/demo_proto
go mod tidy
go run .
  1. 测试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,可读性好,但编写效率不高

思路

  1. 环境改造和确认:创建测试数据库,数据库名叫demo_proto,可以利用mysqldocker容器中的MYSQL_DATABASE这个环境变量参数,在创建镜像启动时指定数据库名
  2. 创建数据模型:biz目录下新增model文件,并且创建user的数据模型
  3. 自动迁移:在dai目录(数据访问层)数据库初始化时支持数据库迁移,会用到gorm的一个特性
  4. 测试代码:在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中配置

  1. 打开保存时自动格式化功能:在设置搜索format on save,勾选打开
  2. 选择具体的link工具:在设置搜索golint,选择golangci-lint
  3. 选择具体的格式化工具:gofumpt

在设置搜索gopls,启用gofumpt

1
2
3
"gopls": {
    "formatting.gofumpt": true
}

在设置搜索go format,选择gofumpt

错误码设计

HTTP 响应状态码 - HTTP | MDN

HTTP 响应状态码用来表明特定 HTTP 请求是否成功完成。 响应被归为以下五大类:

  1. 信息响应 (100199):一些提示性的响应,比如websocket connection、Upgrade,成功就会返回101
  2. 成功响应 (200299):成功响应的一些状态码,比如200、202-成功,204-响应成功但是没有响应内容
  3. 重定向消息 (300399):表示一些重定向,常见的比如301、302、307、308,然后304也比较常见
  4. 客户端错误响应 (400499):表示一些客户端的请求错误,常见的比如400-参数错误、401-未认证、403-没有权限操作、404-not found,资源未找到
  5. 服务端错误响应 (500599):表示一些服务端的错误,常见的比如500,然后502、503、504表示一些网关错误、服务端错误、超时

在一个大型的系统中,会有非常多的自定义错误码,下面是设计一套自定义错误码的一些注意事项

错误码结构

一个错误码由四个部分组成:系统编码、业务编码、错误类型、接口/操作编码

  • 系统编码:用于区分不同的子系统或者模块,如每个系统分配特定的两位或三位数字作为前缀
  • 业务编码:反映具体业务领域或功能模块的错误,如交易商品管理、用户认证等
  • 错误类型:描述具体的错误类型,如数据校验、错误资源未找到、权限不足等
  • 接口/操作编码:描述复杂系统的不同接口或操作,精确指示哪个接口或操作发生了问题

错误码定义

每一个错误码都有其所对应的错误情境和原因,例如20001表示成功、40101表示用户名或密码错误、50000表示服务器内部错误

错误码文档

需要创建详细的错误码文档,包含每个错误码及其解释,以便团队成员和其他用户查询和理解

统一标准

统一团队内的错误码命名和使用规范,避免混淆。同时可以考虑业界通用标准,如HTTP错误代码,保持一定程度的协调一致

易读性和扩展性

错误码应该简洁且易于记忆,同时也要具备良好的扩展性。随着业务的发展和变化,能够方便地新增错误码,而不至于与现有错误码冲突

在实践中,还应当提供错误码的附加信息,如错误消息,以提供用户获得更详尽的错误描述。对于前后端分离的应用,通常会在响应体中返回一个固定字段,如code来表示错误码,并配合message字段来说明错误的具体内容。当code等于特定值,如100000时,表示请求执行成功,其他值则表示不同类型的错误。

错误码的定义没有一个非常严格的规范,主要是系统各部分统一定义,方便后续沟通理解,看到错误码可以大概理解发生了什么错误,方便问题的定位

日志规范

Hertz适配了常见的日志库:hertz-contrib/logger: A collection of Hertz logger extension

结构化日志

常见的日志库可以很方便的打印结构化日志,如logruszapslogzerolog,可以通过添加key value获取更多关键信息,方便后续的检索和分析

打印重要信息

打印信息一定要明确打印出具体的问题和原因,可以增加用户id等关键信息。如果有配置链路追踪系统的话,一般会把traceidspanid打印进日志,最终可以通过链路跟日志串联起来,方便的去跟踪问题

分布式链路跟踪中的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)]

提交类型包括:

  1. feat(功能):添加了新的功能或特性
    • 示例: git commit -m "feat: add search functionality"
  2. fix(修复):修复了某个bug
    • 示例: git commit -m "fix: resolve null pointer exception"
  3. chore(日常任务):完成了日常的维护任务,如更新依赖库、改进构建过程、工具配置等
    • 示例: git commit -m "chore: update dependencies to latest version"
  4. docs(文档):更新了项目的文档,例如修改 README 文件、API 文档等
    • 示例: git commit -m "docs: update API documentation"
  5. style(样式):代码样式的变更,如格式化代码、修正缩进、空格、空行等,例如修改代码结构、变量名、函数名等但没有影响代码逻辑
    • 示例: git commit -m "style: reformat code to follow coding standards"
  6. refactor(重构):对代码进行了重构,优化了代码结构或清理了代码,但没有添加新功能或修复 bug
    • 示例: git commit -m "refactor: optimize data processing logic"
  7. test(测试):添加或更新了测试用例
    • 示例: git commit -m "test: add unit tests for login module"
  8. perf(性能):优化了代码的性能、减少内存占用等
    • 示例: git commit -m "perf: improve query performance by adding indexes"
  9. build(构建):更改了构建系统或外部依赖项,例如修改依赖库、外部接口或者升级 Node 版本等
    • 示例: git commit -m "build: configure webpack for better code splitting"
  10. ci(持续集成):更新了持续集成流程和脚本,例如修改 Travis、Jenkins 等工作流配置
    • 示例: git commit -m "ci: setup continuous deployment with Travis"
  11. 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 通信

  1. 服务端获取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 .
  1. 客户端发送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

demo2-metainfo

kitex不仅支持rpc info,还可以通过metainfo包来实现元信息的正向或反向传递。需要注意的是kitex grpc需要满足CGI网关风格的key,也就是key需要用大写加下划线这种格式。kitex grpc也兼容了原本的metadata的传输方式

下面在demo_proto演示使用metainfo包来传递元数据

  1. 服务端获取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 .
  1. 客户端发送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

错误的传递:kitexkerrors包提供了非常方便的创建错误的一些方法,常见的错误一种是环境异常,比如网络异常等,第二种就是业务异常,比如说参数错误。下面演示一个业务异常

  1. 服务端识别错误请求:修改服务端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 .
  1. 客户端发送错误信息并接受错误信息的响应:修改客户端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

kitexclientserver端都支持配置中间件,而且kitex已经提供了很多开箱即用的中间件:kitex-contrib,常见的中间件比如prometheusopentelemetry这些都有提供具体的用法。

中间件其实就是一些方法,方便我们在请求前或者请求后执行一些逻辑,常见的比如权限校验

下面演示自定义中间件的用法:打印具体逻辑的执行时间

  1. 编写中间件代码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
    }
}
  1. 将中间件应用到服务端: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 .
  1. 将中间件应用到客户端: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 的教学项目,主要针对电商业务场景,基础架构如下

  • 配置中心用的etcd

开发环境参考上一章开头的环境配置,代码位置:~/projects/gomall

前端页面

前端服务是整个项目的入口,是面向C端用户的系统

  • 调用RPC服务:组装的数据会调用相应的RPC服务
  • 前后端不分离:由于本项目只关注后端的技术栈,前端页面几乎没有用到JS,所以前端直接选择用 Hertz 框架,把页面数据放在服务端生成,不做前后端的分离
  • UI组件库:Bootstrap,在HTML标签里添加相关的属性即可实现比较好看的样式
  • 图形库:Font Awesome,有比较多的免费图标,基本能满足项目需求
  • 页面骨架:go template

编码思路

  1. 编写IDL,使用hz生成基于IDLHertz 项目的脚手架,参考:hz 使用 (protobuf) | CloudWeGo

新建gomall/idl/api.protoapi.protohz 提供的注解文件

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
  1. 配置前端首页访问,参考:渲染 | CloudWeGo

新建gomall/app/frontend/template/home.tmpl

1
Hello

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 .
  1. 引入热加载工具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
  1. 引入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", "./")
  1. 编写首页代码

原型图制作: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">&copy; 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

前端内容

用于管理sessionhertz中间件:hertz-contrib/sessions: Sessions middleware for Hertz

用于管理jwthertz中间件:hertz-contrib/jwt: JWT middleware for Hertz

用于管理pasetohertz中间件:hertz-contrib/paseto: Paseto middleware for Hertz.

  • paseto是安全无状态令牌的规范和参考实现,鉴于jwt家族过于自由,容易出现漏洞和不完全算法的使用下,paseto提出了新的更加安全的令牌方案,更加注重安全隐私、易用性和多语言跨平台的兼容性

后端内容

idl

  1. 编写用户服务的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) {}
}
  1. 根据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

  • --passcwgo提供的一个选项,将后续命令传递给底层生成代码的工具——kitex
  • kitex有一个-use的参数可以控制生成服务端代码时不生成客户端代码,直接使用指定模块
  • 这么做的目的是服务端和客户端代码分离

配置连接

  1. 配置数据库连接: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.yamlconf/online/conf.yamlconf/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. 配置服务注册连接
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: ""

配置注册中心的dockergomall/docker-compose.yaml

1
2
3
4
5
6
7
services:
  consul:
    # demo用的consul:1.15.4
    image: 'hashicorp/consul'
    ports:
      - 8500:8500
...

数据模型

  1. 定义用户数据模型: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
}

启动服务

  1. 启动docker容器:consulmysqlredis
1
2
cd ~/projects/gomall
docker compose up

Consul提供了一个WEB界面可以用来查看所有的节点,通过8500端口访问WEB管理界面

  1. 启动用户服务
 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

  1. 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;
}
  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

配置连接

  1. 配置数据库连接: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.yamlconf/online/conf.yamlconf/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. 配置服务注册连接
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: ""

配置注册中心的dockergomall/docker-compose.yaml

1
2
3
4
5
6
7
services:
  consul:
    # demo用的consul:1.15.4
    image: 'hashicorp/consul'
    ports:
      - 8500:8500
...

数据模型

  1. 定义基础数据模型: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
}
  1. 定义商品数据模型: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,
	}
}
  1. 定义类别数据模型: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()为绝对路径

1

业务逻辑-列出

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
}

启动服务

  1. 启动docker容器:consulmysqlredis
1
2
cd ~/projects/gomall
docker compose up
  1. 启动商品服务
1
2
3
4
cd ~/projects/gomall/app/product
go mod tidy
go work use .
go run .

购物车服务

idl

  1. 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 {}
  1. 根据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

配置连接

  1. 配置数据库连接: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.yamlconf/online/conf.yamlconf/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. 配置服务注册连接
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: ""

配置注册中心的dockergomall/docker-compose.yaml

1
2
3
4
5
6
7
services:
  consul:
    # demo用的consul:1.15.4
    image: 'hashicorp/consul'
    ports:
      - 8500:8500
...

数据模型

  1. 定义购物车相关数据模型: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
}

启动服务

  1. 启动docker容器:consulmysqlredis
1
2
cd ~/projects/gomall
docker compose up
  1. 启动购物车服务
1
2
3
4
cd ~/projects/gomall/app/cart
go mod tidy
go work use .
go run .

订单服务

idl

  1. 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;
}
  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

配置连接

  1. 配置数据库连接: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.yamlconf/online/conf.yamlconf/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. 配置服务注册连接
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: ""

配置注册中心的dockergomall/docker-compose.yaml

1
2
3
4
5
6
7
services:
  consul:
    # demo用的consul:1.15.4
    image: 'hashicorp/consul'
    ports:
      - 8500:8500
...

数据模型

  1. 定义订单数据模型: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"
}
  1. 定义订单商品关联模型: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
}

启动服务

  1. 启动docker容器:consulmysqlredis
1
2
cd ~/projects/gomall
docker compose up
  1. 启动订单服务
1
2
3
4
cd ~/projects/gomall/app/order
go mod tidy
go work use .
go run .

支付服务

idl

  1. 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;
}
  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

配置连接

  1. 配置数据库连接: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.yamlconf/online/conf.yamlconf/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. 配置服务注册连接
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: ""

配置注册中心的dockergomall/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

数据模型

  1. 定义基础数据模型:biz/model/base.go
1
2
3
4
5
type Base struct {
	ID        int `gorm:"primarykey"`
	CreatedAt time.Time
	UpdatedAt time.Time
}
  1. 支付相关数据模型: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
}

启动服务

  1. 启动docker容器:consulmysqlredis
1
2
cd ~/projects/gomall
docker compose up
  1. 启动支付服务
1
2
3
4
cd ~/projects/gomall/app/payment
go mod tidy
go work use .
go run .

结算服务

idl

  1. 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;
}
  1. 根据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

配置连接

  1. 配置数据库连接: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.yamlconf/online/conf.yamlconf/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. 配置服务注册连接
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: ""

配置注册中心的dockergomall/docker-compose.yaml

1
2
3
4
5
6
7
services:
  consul:
    # demo用的consul:1.15.4
    image: 'hashicorp/consul'
    ports:
      - 8500:8500
...

微服务间通信

  • 结算服务会先调用购物车服务,拿到购物车中的商品信息,然后计算总价,再调用支付服务,创建未支付订单,若支付成功,则标记支付订单成功
  1. 调用购物车、商品、支付和订单四个服务: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
}

启动服务

  1. 启动docker容器:consulmysqlredis
1
2
cd ~/projects/gomall
docker compose up
  1. 启动结算服务
1
2
3
4
cd ~/projects/gomall/app/checkout
go mod tidy
go work use .
go run .

通知服务

idl

  1. 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);
}
  1. 根据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

  • --passcwgo提供的一个选项,将后续命令传递给底层生成代码的工具——kitex
  • kitex有一个-use的参数可以控制生成服务端代码时不生成客户端代码,直接使用指定模块
  • 这么做的目的是服务端和客户端代码分离

配置连接

  1. 配置服务注册连接
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: ""

配置注册中心的dockergomall/docker-compose.yaml

1
2
3
4
5
6
7
services:
  consul:
    # demo用的consul:1.15.4
    image: 'hashicorp/consul'
    ports:
      - 8500:8500
...
  1. 配置消息中间件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...)

业务逻辑-消费

通知服务从消息队列拿到消息后发送邮件

  1. 发送邮件: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{}
}
  1. 消费者订阅主题、处理消息:发送邮件,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()
	})
}
  1. 初始化所有消费者:email/biz/consumer/consumer.go
1
2
3
func Init() {
	email.ConsumerInit()
}
  1. 服务启动时连接到消息队列并初始化消费者:main.go
1
2
3
4
5
6
7
opts := kitexInit()

// 连接到消息队列服务器并初始化消费者
mq.Init()
consumer.Init()

svr := emailservice.NewServer(new(EmailServiceImpl), opts...)

业务逻辑-生产

结算服务完成后向消息队列放入消息

  1. 下载nats客户端的go
1
go get github.com/nats-io/nats.go@latest
  1. 连接到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)
	}
}
  1. 结算服务启动时连接到消息队列服务器:main.go
1
2
3
4
5
6
opts := kitexInit()

// 连接到消息队列服务器
mq.Init()

svr := emailservice.NewServer(new(EmailServiceImpl), opts...)
  1. 生产者发布消息: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)
...

启动服务

  1. 启动docker容器:consulmysqlredis
1
2
cd ~/projects/gomall
docker compose up
  1. 启动服务
 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也提供了很多服务发现的机制,比如文件、K8Sconsul
    • 启动各个微服务应用时,将服务信息注册到consul里面,包括服务地址、端口路径,Prometheus server通过服务发现的规则去consul获取
    • 服务数量比较少时,也可以通过YAML配置文件的方式去配置各个服务
  • 可视化:当server获取到指标之后,就可以通过Grafana连接到Prometheus去做检索和面板的配置
  • 监控告警:Prometheus也提供了监控告警的组件Alertmanager:通过一些预定义的规则,如果有符合告警的规则,就会把告警内容推送到AlertmanagerAlertmanager负责把它通过邮件或者通讯工具等发送出去
  • 收集指标:Prometheus推荐使用拉模式来收集指标,但是也提供了一种Pushgateway来满足一些特定的场景
    • Pushgateway主要的应用场景是定时任务、批处理等,这些服务通常是非常短暂的,他们将运行过程中产生的一些指标推送到Pushgateway,最终PrometheusPushgateway来拉取指标

kitex集成prometheusGithub,其基于Prometheus Go SDK

基本配置

  1. 引入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
  1. 编写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 := &registry.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
}
  1. 编写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
  1. 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客户端

  1. cart新增配置:cart/conf/conf.go
1
2
3
4
type Kitex struct {
    ...
    MetricsPort     string `yaml:"metrics_port"`
}
  1. cart/conf/test/conf.yamlcart/conf/dev/conf.yamlcart/conf/online/conf.yaml
1
2
3
4
5
kitex: 
	...
	address: ":8883"
	metrics_port: ":9993"
	...
  1. 改造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
}
  1. 修改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
}
  1. 改造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
}
  1. 修改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)
}
  1. 将以上改动复制到其他服务,metrics_port最后一位看服务地址
  2. 检查各服务中的common是否用的本地common模块

启动服务

  1. 启动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 .
  1. 访问localhost:8500在注册中心可以看到有七个kitex服务,以及prometheus服务下有七个实例
  2. 访问localhots:9993/metrics查看有cart服务的metrics
  3. 访问localhost:3000,在Grafana中添加Data sourcesconnetionprometheus server URLhttp://prometheus:9090Save & test;复制biz-demo/gomall/deploy/grafana.jsonDashboards右上角New->Import,然后Data sources选刚才添加的,Namekitex,点击Import,即可看到仪表盘可视化界面

可观测性 - 链路

OpenTelemetry是可观测性一个通用的解决方案,也算是CNCF可观测性领域的一个事实标准。GitHub

  • 各种服务生成链路数据,数据上报到collector
  • collector主要包括三个部分:receiver负责接收数据、processor负责处理数据、exporter负责导出数据

对于OpenTelemetrycloudwego社区提供了相应的适配,方便生成链路数据及指标

基本配置

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))
	...
}

启动服务

  1. 启动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 .
  1. 访问localhost:16686查看链路数据

可观测性-日志

日志管理方案

开源日志管理方案 ELK 和 EFK 的区别 - 腾讯云

ELK:Elasticsearch + Logstash + Kibana

EFK:Elasticsearch + Filebeat or Fluentd + Kibana

  • Filebeats :收集本地 log 数据
  • Logstash:收集分布在各处的 log 并进行处理
  • Elasticsearch :集中存储 log 数据
  • KibanaElasticsearch 开发的前端 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

启动服务

启动服务

  1. 启动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 .
  1. 访问localhost:3000,打开Grafana,添加一个data source: lokiconnectionhttp://loki:3100Save & test

下面部分只学习理论,暂未实操

容器化与部署

What is a Container? | Docker

CNCF Landscape

步骤:

  1. 编写Dockerfile生成各个服务的镜像
  2. 编写docker-compose.yaml启动各个服务容器
  3. 部署在k8s中:使用kind在本地部署一个k8s集群

服务优化

缓存、微服务的稳定性、配置中心的使用

缓存

缓存:减少对底层速度较慢的存储层的访问需求,以此来提高数据检索性能

  • 给商品服务增加Redis缓存:通过Id查询商品,先查缓存,再查数据库,最后放到缓存

微服务的稳定性

微服务架构因其灵活性和可扩展性而广受欢迎,但同时带来了服务治理、监控和稳定性方面的挑战;确保微服务的高可用和稳定性,是构建可靠分布式系统的关键

Kitex提供了常见的服务治理特性:服务注册、服务发现、配置中心、负载均衡以及超时控制、重试策略包括熔断、fall back和限速

熔断器作用:在进行 RPC 调用时,下游服务难免会出错;当下游出现问题时,如果上游继续对其进行调用,既妨碍了下游的恢复,也浪费了上游的资源;为了解决这个问题,可以设置一些动态开关,当下游出错时,手动的关闭对下游的调用;然而更好的办法是使用熔断器,自动化的解决这个问题。

FallBack:业务在 RPC 请求失败后通常会有一些降级措施保证有效返回(比如请求超时、熔断后,构造默认返回),Kitex 的 Fallback 支持对所有异常请求进行处理。同时,因为业务异常通常会通过 Resp(BaseResp) 返回,所以也支持对 Resp 进行处理

  • 在前端rpc调用商品服务中增加熔断策略和Fallback:根据RPC的成功失败情况,限制对下游的访问,预设一个返回

配置中心

配置中心的用法

  • 常规意义的配置中心:把配置存储到配置中心,实现配置的统一管理
  • Kitex 提供的服务治理相关功能对配置中心的集成:kitex-contrib/config-consul

CICD

CIcontinuous integration,持续集成,这种开发实践要求开发成员,频繁的将代码提交到版本库,并且每次提交都要自动化构建,进行编译和测试的验证,尽早发现集成的错误。集成工具要求快速反馈构建和测试的结果,帮助开发人员及早的发现和修复问题

  • 自动化构建和测试
  • 频繁的集成
  • 快速的反馈

CD:持续交付或持续部署,这两个虽然有所不同,但它们都是自动化软件交付的一部分

  • 持续交付通过自动化流程,确保随时可发布的状态,交付给用户。开发者可以选择性的进行发布,持续交付的目标是降低发布新版本的成本、风险和实践,并确保软件的高质量
  • 持续部署是在持续交付的进一步演变,其核心理念是每次代码变更,都通过了自动化测试和构建,并且能够自动化发布到生产环境,没有人工干预。这种会要求强大的自动化测试和监控系统,以确保部署到生产环境的代码是稳定和可靠的

持续交付会侧重于将软件的每个版本都保持在一个可以随时交付的状态,但是部署是手动触发的

持续部署是进步了一个部署的过程,它使得每个通过测试的变更,都可以自动的部署到生产环境,但前提是通过了所有的自动化测试和审批流程

无论是持续交付还是持续部署,他们都强调通过自动化来提高软件开发和交付的效率、质量和可靠性,这些是现代DevOps实践中的重要组成部分

CI/CD的重要性

  • 提高软件的质量,确保每次提交都经过严格验证
  • 加速产品的迭代,缩短从代码提交到生产部署的时间
  • 减少人工错误
  • 提高团队的协作效率

CI/CD的生态工具

  • GitHub Action:集成于GitHub
  • GitLab CIGitLab内置的CI/CD解决方案
  • Jenkins:开源的CI/CD服务社区,java
  • Travis CI:专注于持续集成,支持多种语言
  • Argo CD:功能强大的开源工具,适合于 k8s 环境中实现持续交付

实践步骤

  1. 选择一个合适的CI/CD工具
  2. 设计CI/CD Pipeline:代码拉取 -> 构建与测试 -> 静态代码分析 -> 打包 -> 部署
  3. CI/CD高级特性:环境变量和密钥配置、并行构建与测试、自定义的脚本和命令
  4. 最佳实践:代码审查和分支策略、自动化测试的覆盖率、监控和报警、回滚策略

网站访问

域名+https+cdn

黑盒监控与告警

可观测性的一些延伸,同时也是应用上线后最后一些基础建设的补充

白盒监控:前边做的可观测性是通过内部数据和指标,来监控系统的性能和健康状态

黑盒监控:模拟用户的操作,不需要了解系统内部的实现细节,从外部观察系统的行为,评估系统的性能和可用性

Prometheus Blackbox Exporter

  1. Prometheus通过HTTP请求,向Blackbox Exporter发送探测的任务
  2. Blackbox Exporter根据配置的探测模块,对目标进行一个探测,把结果返回给Prometheus
  3. Prometheus定期的去抓取这些结果分析,然后存储在它的数据库中

ExporterPrometheus监控体系的一个重要的组件或者架构组成,负责从各种系统和服务中收集指标数据,并暴露给PrometheusExporter也可以理解为特定的监控指标采集工具,由Prometheus根据配置抓取解析进行存储

Built with Hugo
Theme Stack designed by Jimmy