抖音大项目开发部署全流程<壹>(gorm操作数据库与ffmpeg制作视频封面)|青训营笔记

225 阅读6分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第2篇笔记。

这篇笔记主要记录一下在抖音后端大项目开发中Gorm库操作Mysql数据库的一些细节,以及针对业务逻辑建表的策略。然后就是在使用ffmpeg in docker截取封面图的一些基本用法。

Gorm操作Mysql数据库

首先,Gorm库是一个全功能的ORM,基本的设计和用法在专门的Gorm课程中老师已经讲得非常清楚清晰了,笔记中针对一些使用上的优化、细节以及建表逻辑简单的介绍了一下。

Gorm的Logger打印细节

在大部分微服务的开发中,会将Gorm连接池放在一个全局包里,在main函数中会初始化这个全局包,并在init函数中初始化连接池。

package global
var (
	MysqlInfo   cfg.MysqlConfig
	DB          *gorm.DB
)
func init() {
	InitDB()
}

但是Gorm是不会默认开启日志打印和sql打印的,在开发和维护过程中会导致难以定位bug位置和逻辑错误位置。

在开发过程中,其实有很多次是通过查sql语句来定位bug的。因为你操作ORM的时候,你觉得应该进行的逻辑和真实运行的逻辑可能有差别,这样也会造成bug。

func InitDB() {
	addr := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
		MysqlInfo.User,
		MysqlInfo.Password,
		MysqlInfo.Host,
		MysqlInfo.Port,
		MysqlInfo.Name)
	newLogger := logger.New(
		log.New(os.Stdout, "\r\n", log.LstdFlags),
		logger.Config{
			SlowThreshold: time.Second,
			LogLevel:      logger.Info,
			Colorful:      true,
		},
	)
	var err error
	DB, err = gorm.Open(mysql.Open(addr), &gorm.Config{
		NamingStrategy: schema.NamingStrategy{
			SingularTable: true,
		},
		Logger: newLogger,
	})
	if err != nil {
		panic(err)
	}
	fmt.Println("数据库连接成功")
}

其中newLogger是配置了一个Logger将日志打印在标准输出流中,并且配置了慢查询警告阈值,打印日志级别,打印颜色等。

数据库表的设计细节

在整个项目中,最主要的两张表就是用户表视频表以及一个评论表(不是那么重要),针对其中的点赞关注等操作,表中存在一定的关联关系。根据这种关系,有两种解决方案:

  1. 使用Gorm提供的关联关系来设计表(外键)。
  2. 根据这种关联关系自己维护两个表。

使用Gorm自带关联关系来设计表

在struct中使用foreignkey或者many2many来建立约束,详细使用方法见官方文档,代码如下:

type BaseModeltest struct {
	ID        int            `gorm:"primarykey;type:int" json:"id"`
	CreatedAt time.Time      `gorm:"column:add_time" json:"-"`
	UpdatedAt time.Time      `gorm:"column:update_time" json:"-"`
	DeletedAt gorm.DeletedAt `json:"-"`
	IsDeleted bool           `json:"-"`
}
type Usertest struct {
	BaseModel
	UserName  string      `gorm:"index:idx_username,unique;type:varchar(40);not null"`
	Password  string      `gorm:"type:varchar(40);not null"`
	Following *[]Usertest `gorm:"many2many:usertest_following;"`
	Follower  *[]Usertest `gorm:"many2many:usertest_follower;"`
}
type Videotest struct {
	BaseModel
	CreatedAt    time.Time  `gorm:"column:add_time;not null;index:idx_add" `
	Author       User       `gorm:"foreignkey:AuthorID"`
	AuthorID     int        `gorm:"index:idx_authorid;not null"`
	PlayUrl      string     `gorm:"type:varchar(255);not null"`
	CoverUrl     string     `gorm:"type:varchar(255)"`
	Likers       []Usertest `gorm:"many2many:videotest_usertest;"`
	CommentCount int        `gorm:"default:0"`
}

这样的话Gorm也会帮忙创建中间表,但是会根据User表创建两个中间表,usertest_followingusertest-follower,分别用来记录用户的跟随者和被跟随者。在用户信息的高频查询的情况下,统计数据数量和Join查询操作消耗也是不可忽视的。因此我采用了第二种方式。

自己创建一个中间表,通过事务来同步粉丝(偶像)数量,用MVCC来并发控制

自己创建了一个中间表,表中维持了和User的外键关系,用事务来同步更新数量。表结构代码如下:

type BaseModel struct {
	ID        int            `gorm:"primarykey;type:int" json:"id"`
	CreatedAt time.Time      `gorm:"column:add_time" json:"-"`
	UpdatedAt time.Time      `gorm:"column:update_time" json:"-"`
	DeletedAt gorm.DeletedAt `json:"-"`
	IsDeleted bool           `json:"-"`
}
type User struct {
	BaseModel
	UserName       string `gorm:"index:idx_username,unique;type:varchar(40);not null"`
	Password       string `gorm:"type:varchar(40);not null"`
	FollowingCount int    `gorm:"default:0"`
	FollowerCount  int    `gorm:"default:0"`
}

type Relation struct {
	UserFrom   User `gorm:"foreignkey:FollowFrom"`
	UserTo     User `gorm:"foreignkey:FollowTo"`
	FollowFrom int  `gorm:"index:idx_follow_from_to,unique;type:int;not null"`
	FollowTo   int  `gorm:"index:idx_follow_from_to,unique;index:idx_follow_to;type:int;not null"`
}
type Video struct {
	BaseModel
	CreatedAt     time.Time `gorm:"column:add_time;not null;index:idx_add" `
	Author        User      `gorm:"foreignkey:AuthorID"`
	AuthorID      int       `gorm:"index:idx_authorid;not null"`
	PlayUrl       string    `gorm:"type:varchar(255);not null"`
	CoverUrl      string    `gorm:"type:varchar(255)"`
	FavoriteCount int       `gorm:"default:0"`
	CommentCount  int       `gorm:"default:0"`
	Title         string    `gorm:"type:varchar(50);not null"`
}

type FavoriteVideo struct {
	User    User  `gorm:"foreignkey:UserID"`
	UserID  int   `gorm:"index:idx_userid_videoid,unique;not null"`
	Video   Video `gorm:"foreignkey:VideoID"`
	VideoID int   `gorm:"index:idx_userid_videoid,unique;index:idx_videoid;not null"`
}

虽然第二种方式查询代码是需要自己手动控制多一点,但的确少了一个中间表,插入逻辑也变得简单了一些。到底哪种方式对业务更好,说实话我也没测试过,等懂的佬们说说看。

使用FFmpeg生成封面图

在项目中后端会收到用户上传的视频,有些视频文件里是不会带封面图等信息的,这时候就有需求从视频中截取一张封面了。

这时候,大家就会记起著名的FFmpeg了,FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序(来自百度百科)。简单来说,用这个软件就能轻松地从视频中截取一张图保存下来,那么就可以把这张图作为封面图保存起来。

FFmpeg支持多平台,多种架构,例如x86-64,arm64armhf,但是不同平台的安装给我这种只需简单需求的玩家来说并不是很友好。 但是FFmpeg官方利用docker manifest来实现了多平台感知。

即装在docker镜像中,作为一个单独的组件,使用的时候通过docker run exec等命令来调用。详细文档见官方或Docker hub:hub.docker.com/r/linuxserv…

在我的需求中需要截取视频中某一秒的一帧,在Go中取得docker运行的管理员权限后,代码如下:

cmd := []string{
	"$(docker run --rm -i -v", // --rm run完后删除
	Dir + ":/tmp",             // 将本地Dir文件夹挂载到镜像中的/tmp
	"linuxserver/ffmpeg",      // 镜像中运行ffmpeg
	fmt.Sprintf("-i /tmp/test%d.mp4", cnt),   // -i 挂载到镜像中的文件名
	"-ss 00:00:05",            // 截图第5秒
	"-frames:v 1 test.png",    // 截一帧 保存到test.png
}
err := exec.Command("/bin/bash", "-c", strings.Join(cmd, " ")).Run()
if err != nil {
	...
}

这只是一个截图的demo,具体的还能控制截图的大小、清晰度、使用硬件加速等参数。

当然,如果是专门部署到一个处理视频的服务器上这也是可以的,但是之后为了微服务应用镜像的体积和用户体验,我将视频存储放到了腾讯cos服务器上了。于此同时,腾讯cos服务提供了一个工作流,可以将上传过来的视频直接截帧并保存下来,所以后面的两种部署方式关于静态资源的保存全部放在了腾讯cos服务器上了。

前两个笔记主要是讲了开发前期一些工作和技术栈选择,具体的业务逻辑的实现也是做到了能跑就行的程度,具体的优化我是放在了gRPC的负载均衡以及面对高并发的部署上,其关键也是放在后面三篇笔记。