[ 项目记录 1基建 | 青训营笔记 ]

48 阅读7分钟

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, &registry.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数据库连通了,先这样