fine,正式开始写项目了,这一次的架构就参考 easy note,感谢组里各个大佬呜呜呜,这一次先做好一下基建,数据库和服务发现,项目必要的下载在[ 项目记录 0 | 青训营笔记 ] - 掘金 (juejin.cn)
技术栈:
- 主语言:go
- orm框架:gorm + gorm.gen
- 数据库:mysql + redis
- http框架:hertz
- rpc框架:kitex
- 服务注册与服务发现:nacos
- idl:thrift
- 链路追踪:opentelemetry + jaeger
- 安装部署:docker compose
- 监控数据分析:Grafana + VictoriaMetrics
项目结构:
.
├── README.md
├── cmd # 逻辑目录
│ ├── api # 网关
│ ├── user # 用户
│ └── video # 视频
├── docker-compose.yaml # 项目配置部署
├── go.mod
├── idl # 接口文件
│ ├── gateway.thrift
│ ├── user
│ └── video
├── kitex_gen # kitex生成的文件
│ ├── userservice
│ └── videoservice
└── pkg
├── cache # init
├── configs # 数据库配置
├── consts # 配置目录
├── dal # gorm/gen 生成的文件
├── errno # 错误输出
├── mw # 中间件 打印rpc信息
└── utils # jwt
架构
http
┌────────────────────────┐
┌─────────────────────────┤ ├───────────────────────────────┐
│ │ apigateway │ │
│ ┌──────────────────► │◄──────────────────────┐ │
│ │ └───────────▲────────────┘ │ │
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │
│ │ resolve │ │
│ │ │ │ │
req resp │ resp req
│ │ │ │ │
│ │ │ │ │
│ │ │ │ │
│ │ ┌──────────▼─────────┐ │ │
│ │ │ │ │ │
│ │ ┌───────────► Nacos ◄─────────────────┐ │ │
│ │ │ │ │ │ │ │
│ │ │ └────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ register register │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
┌▼──────┴───────┴───┐ ┌──┴────────┴───────▼─┐
│ │───────────────── req ────────────────────►│ │
│ User │ │ Video │
│ │◄──────────────── resp ────────────────────│ │
└───────────────────┘ └─────────────────────┘
thrift kitex thrift kitex
Link Start!
IDL 架构生成
基础接口官方有给 抖音项目方案说明 proto
形式,所以 user与 video 就用 proto 通过 kitex 来创建,网关就用 thrift
p.s. 这里还是尽量用一种 idl 来生成,不然接口还是有点不通用(能写是能写),这里建议使用 thrift
这边留几个标志性的接口
// 网关
namespace go api
// 用户结构体
struct User {
1: i64 id // 用户唯一标识
2: string name // 用户名
3: i64 follow_count // 关注数
4: i64 follower_count // 被关注数
5: bool is_follow // 是否关注
}
// 视频结构体
struct Video {
1: i64 id // 视频唯一标识
2: User author // 发布者
3: string play_url // 视频链接
4: string cover_url // 封面地址
5: i64 favorite_count // 视频的点赞总数
6: i64 comment_count // 视频的评论总数
7: bool is_favorite // true-已点赞,false-未点赞
8: string title // 视频标题
}
// 评论结构体
struct Comment {
1: i64 id // 视频评论id
2: User user // 评论用户信息
3: string content // 评论内容
4: string create_date // 评论发布日期,格式 mm-dd
}
// 视频请求
struct FeedReq {
1: optional string latest_time
2: optional string token
}
// 视频响应
struct FeedResp {
1: i64 status_code
2: string status_msg
3: i64 next_time
4: list<Video> video_list
}
// 用户注册请求
struct UserRegisterReq {
1: string username (api.query="username", api.vd="len($) <= 32")
2: string password (api.query="password", api.vd="len($) <= 32>")
}
// 用户注册响应
struct UserRegisterResp {
1: i64 status_code
2: string status_msg
3: i64 user_id
4: string token
}
// 用户登录请求
struct UserLoginReq {
1: string username (api.query="username", api.vd="len($) <= 32")
2: string password (api.query="password", api.vd="len($) <= 32")
}
// 用户登录响应
struct UserLoginResp {
1: i64 status_code
2: string status_msg
3: i64 user_id
4: string token
}
// 视频投稿接口请求
struct PublishActionReq {
1: list<byte> data
2: string token
3: string title
}
// 视频投稿接口响应
struct PublishActionResp {
1: i64 status_code
2: string status_msg
}
// 评论操作接口请求
struct CommentActionReq {
1: string token
2: string video_id
3: string action_type
4: optional string comment_text
5: optional string comment_id
}
// 评论操作接口响应
struct CommentActionResp {
1: i64 status_code
2: string status_msg
3: Comment comment
}
// 关系操作接口请求
struct RelationActionReq {
1: string token
2: string to_user_id
3: string action_type
}
// 关系操作接口响应
struct RelationActionResp {
1: i64 status_code
2: string status_msg
}
// 用户粉丝列表请求
struct RelationFollowerListReq {
1: string user_id
2: string token
}
// 用户粉丝列表响应
struct RelationFollowerListResp {
1: i64 status_code
2: string status_msg
3: list<User> user_list
}
// 用户好友列表请求
struct RelationFriendListReq {
1: string user_id
2: string token
}
// 用户好友列表响应
struct RelationFriendListResp {
1: i64 status_code
2: string status_msg
3: list<User> user_list
}
service ApiService {
FeedResp Feed(1: FeedReq req) (api.get="/douyin/feed") // 视频流接口
UserRegisterResp UserRegister(1: UserRegisterReq req) (api.post="/douyin/user/register") // 用户注册接口
UserLoginResp UserLogin(1: UserLoginReq req) (api.post="/douyin/user/login") // 用户登录接口
PublishActionResp PublishAction(1: PublishActionReq req) (api.post="/douyin/publish/action") // 视频投稿接口
CommentActionResp CommentAction(1: CommentActionReq req) (api.post="/douyin/comment/action") // 评论操作接口
RelationActionResp RelationAction(1: RelationActionReq req) (api.post="/douyin/relation/action") // 关系操作接口
RelationFollowerListResp RelationFollowerList(1: RelationFollowerListReq req) (api.get="/douyin/relation/follower/list") // 用户粉丝列表接口
RelationFriendListResp RelationFriendList(1: RelationFriendListReq req) (api.get="/douyin/relation/friend/list") // 用户好友列表接口
}
生成框架
# api
init_api:
hz new -mod mini-min-tiktok -idl ../../idl/gateway.thrift
update_api:
hz update -mod mini-min-tiktok -idl ../../idl/gateway.thrift
# video
server:
kitex -module mini-min-tiktok idl/video.thrift # execute in the project root directory
kitex -module mini-min-tiktok -service videoservice -use mini-min-tiktok/kitex_gen -I=../../idl/ video.thrift # execute in cmd/video
# user
server:
kitex -module mini-min-tiktok idl/video.thrift # execute in the project root directory
kitex -module mini-min-tiktok -service userservice -use mini-min-tiktok/kitex_gen -I=../../idl/ video.thrift # execute in cmd/user
之后项目结构是这样
├─idl
├─cmd
│ ├─api
│ ├─user
│ └─video
├─kitex_gen
│ ├─userservice
│ └─videoservice
└─pkg
项目配置
服务注册
docker 官网搜索所需的示例
kitex 链路追踪 链路跟踪 | CloudWeGo
服务注册 采用 nacos nacos-sdk-go/README
user 的 客户端配置 video类似
func initUser() {
// 接入OpenTelemetry 链路追踪
provider.NewOpenTelemetryProvider(
provider.WithServiceName(consts.VideoServiceName),
provider.WithExportEndpoint(consts.ExportEndpoint),
provider.WithInsecure(),
)
// 服务端配置
// 创建clientConfig的另一种方式
sc := []constant.ServerConfig{
*constant.NewServerConfig(consts.NacosAddr, consts.NacosPort),
}
// 客户端配置
cc := constant.ClientConfig{
NamespaceId: "public", // ACM的命名空间Id
TimeoutMs: 5000, // 请求Nacos服务端的超时时间,默认是10000ms
NotLoadCacheAtStart: true, // 在启动的时候不读取缓存在CacheDir的service信息
LogDir: "/tmp/nacos/log", // 日志存储路径
CacheDir: "/tmp/nacos/cache", // 缓存service信息的目录,默认是当前运行目录
LogLevel: "info", // 日志默认级别,值必须是:debug,info,warn,error,默认值是info
Username: "nacos", // Nacos服务端的API鉴权Username
Password: "nacos", // Nacos服务端的API鉴权Password
}
// 创建服务发现客户端
r, err := clients.NewNamingClient(
vo.NacosClientParam{
ClientConfig: &cc,
ServerConfigs: sc,
},
)
if err != nil {
panic(err)
}
// 为IDL中定义的服务创建客户端
c, err := userservice.NewClient(
consts.UserServiceName,
client.WithResolver(resolver.NewNacosResolver(r)), // 解析器
client.WithMuxConnection(1), // 最大连接数
client.WithMiddleware(mw.CommonMiddleware), // 打印信息
client.WithInstanceMW(mw.ClientMiddleware), // 服务端地址和超时信息
client.WithSuite(tracing.NewClientSuite()), // 选项套件
client.WithClientBasicInfo(&rpcinfo.EndpointBasicInfo{ServiceName: consts.VideoServiceName}),
)
if err != nil {
panic(err)
}
userService = c
}
服务端
svr := userservice.NewServer(new(UserserviceImpl),
server.WithServiceAddr(utils.NewNetAddr(consts.TCP, fmt.Sprintf("127.0.0.1%v", consts.UserServiceAddr))),
server.WithLimit(&limit.Option{MaxConnections: 2000, MaxQPS: 500}),
server.WithMiddleware(mw.CommonMiddleware),
server.WithMiddleware(mw.ServerMiddleware),
server.WithServerBasicInfo(&rpcinfo.EndpointBasicInfo{ServiceName: consts.UserServiceName}),
server.WithSuite(tracing.NewServerSuite()),
server.WithRegistry(registry.NewNacosRegistry(cli)),
)
网关配置
r := nacos.NewNacosRegistry(narcosis)
tracer, cfg := tracing.NewServerTracer()
addr := "0.0.0.0:8080"
h := server.New(
server.WithHostPorts(addr),
server.WithTraceLevel(stats.LevelDetailed),
server.WithHandleMethodNotAllowed(true),
server.WithRegistry(r, ®istry.Info{
ServiceName: consts.ApiServiceName,
Addr: utils.NewNetAddr("tcp", addr),
Weight: 10,
Tags: nil,
}),
tracer,
)
数据库配置
编写 sql 在Navicat里转储 sql 文件,在docker compose.yaml
里添加
# MySQL
mysql:
image: mysql:latest
volumes:
- ./pkg/configs/sql:/docker-entrypoint-initdb.d // 数据库文件读取地址
ports:
- "3306:3306"
environment:
- MYSQL_DATABASE=gorm
- MYSQL_USER=gorm
- MYSQL_PASSWORD=gorm
- MYSQL_RANDOM_ROOT_PASSWORD="yes"
用 gorm - gen 生成模板文件
func main() {
// 连接数据库
db, err := gorm.Open(mysql.Open(DSN))
if err != nil {
panic(fmt.Errorf("cannot establish db connection: %w", err))
}
// 生成实例
g := gen.NewGenerator(gen.Config{
// 相对执行`go run`时的路径, 会自动创建目录
OutPath: "pkg/dal/query",
// WithDefaultQuery 生成默认查询结构体(作为全局变量使用), 即`Q`结构体和其字段(各表模型)
// WithoutContext 生成没有context调用限制的代码供查询
// WithQueryInterface 生成interface形式的查询代码(可导出), 如`Where()`方法返回的就是一个可导出的接口类型
Mode: gen.WithDefaultQuery | gen.WithQueryInterface,
// 表字段可为 null 值时, 对应结体字段使用指针类型
FieldNullable: true, // generate pointer when field is nullable
// 表字段默认值与模型结构体字段零值不一致的字段, 在插入数据时需要赋值该字段值为零值的, 结构体字段须是指针类型才能成功, 即`FieldCoverable:true`配置下生成的结构体字段.
// 因为在插入时遇到字段为零值的会被GORM赋予默认值. 如字段`age`表默认值为10, 即使你显式设置为0最后也会被GORM设为10提交.
// 如果该字段没有上面提到的插入时赋零值的特殊需要, 则字段为非指针类型使用起来会比较方便.
FieldCoverable: false, // generate pointer when field has default value, to fix problem zero value cannot be assign: https://gorm.io/docs/create.html#Default-Values
// 模型结构体字段的数字类型的符号表示是否与表字段的一致, `false`指示都用有符号类型
FieldSignable: false, // detect integer field's unsigned type, adjust generated data type
// 生成 gorm 标签的字段索引属性
FieldWithIndexTag: false, // generate with gorm index tag
// 生成 gorm 标签的字段类型属性
FieldWithTypeTag: true, // generate with gorm column type tag
})
// 设置目标 db
g.UseDB(db)
// 自定义字段的数据类型
// 统一数字类型为int64,兼容protobuf
dataMap := map[string]func(detailType string) (dataType string){
"tinyint": func(detailType string) (dataType string) { return "bool" },
"smallint": func(detailType string) (dataType string) { return "int64" },
"mediumint": func(detailType string) (dataType string) { return "int64" },
"bigint": func(detailType string) (dataType string) { return "int64" },
"int": func(detailType string) (dataType string) { return "int64" },
}
// 要先于`ApplyBasic`执行
g.WithDataTypeMap(dataMap)
// 创建模型的结构体,生成文件在 model 目录; 先创建的结果会被后面创建的覆盖
// 这里创建个别模型仅仅是为了拿到`*generate.QueryStructMeta`类型对象用于后面的模型关联操作中
// 创建全部模型文件, 并覆盖前面创建的同名模型
allModel := g.GenerateAllTable()
g.ApplyBasic(allModel...)
g.Execute()
}
读取配置连接数据库
dsn := fmt.Sprintf("%v:%v@tcp(%v:%v)/%v?charset=utf8mb4&parseTime=True&loc=Local",
viper.GetString("db.username"),
viper.GetString("db.password"),
viper.GetString("db.host"),
viper.GetInt("db.port"),
viper.GetString("db.database"),
)
db, err := gorm.Open(mysql.Open(dsn),
&gorm.Config{
PrepareStmt: true,
Logger: gormlogrus,
},
)
if err != nil {
panic(err)
}
if err = db.Use(tracing.NewPlugin()); err != nil {
panic(err)
}
fmt.Println("数据库连接成功")
if db == nil {
log.Println("db is nil")
}
query.SetDefault(db)
fine数据库连通了,先这样