/byte_douyin_project

字节跳动青训营——抖音项目

Primary LanguageGoMIT LicenseMIT

抖音极简版

Contributors Forks Stargazers Issues MIT License

数据库说明

database.png

单纯看上面的图会感觉很混乱,现在我们来将关系拆解。

数据库关系说明

关系图如下:

database_relation.png

所有的表都有自己的id主键为唯一的标识。

user_logins:存下用户的用户名和密码

user_infos:存下用户的基本信息

videos:存下视频的基本信息

comment:存下每个评论的基本信息

具体的关系索引:

所有的一对一和一对多关系,只需要在一个表中加入对方的id索引。

  • 比如user_infos和user_logins的一对一关系,在user_logins中加入user_id字段设为外键存储user_infos中对应的行的id信息。
  • 比如user_infos和和videos的一对多关系,在videos中加入user_id字段设为外键存储user_infos中对应的行的id信息。

所有的多对多关系,需要多建立一张表,用该表作为媒介存储两个对象的id作为关系的产生,而它们各自表中不需要再存下额外的字段。

  • 比如user_infos和videos的多对多关系,创建一张user_favor_videos中间表,然后将该表的字段均设为外键,分别存下user_infos和videos对应行的id。如id为1的用户对id为2的视频点了个赞,那么就把这个1和2存入中间表user_favor_videos即可。

数据库建立说明

数据库各表的建立无需自己实现额外的建表操作,一切都由gorm框架自动建表,具体逻辑在models层的代码中。

gorm官方文档链接:链接

建表和初始化操作由init_db.go来执行:

func InitDB() {
	var err error
	DB, err = gorm.Open(mysql.Open(config.DBConnectString()), &gorm.Config{
		PrepareStmt:            true, //缓存预编译命令
		SkipDefaultTransaction: true, //禁用默认事务操作
		//Logger:                 logger.Default.LogMode(logger.Info), //打印sql语句
	})
	if err != nil {
		panic(err)
	}
	err = DB.AutoMigrate(&UserInfo{}, &Video{}, &Comment{}, &UserLogin{})
	if err != nil {
		panic(err)
	}
}

架构说明

architecture.png

以用户登录为例共需要经过以下过程:

  1. 进入中间件SHAMiddleWare内的函数逻辑,得到password明文加密后再设置password。具体需要调用gin.Context的Set方法设置password。随后调用next()方法继续下层路由。
  2. 进入UserLoginHandler函数逻辑,获取username,并调用gin.Context的Get方法得到中间件设置的password。再调用service层的QueryUserLogin函数。
  3. 进入QueryUserLogin函数逻辑,执行三个过程:checkNum,prepareData,packData。也就是检查参数、准备数据、打包数据,准备数据的过程中会调用models层的UserLoginDAO。
  4. 进入UserLoginDAO的逻辑,执行最终的数据库请求过程,返回给上层。

各模块代码详细说明

我开发的过程中是以单个函数为单个文件进行开发,所以代码会比较长,故我根据数据库内的模型对函数文件进行了如下分包:

handlers.png

service层的分包也是一样的。

Handlers

对于handlers层级的所有函数实现有如下规范:

所有的逻辑由代理对象进行,完成以下两个逻辑

  1. 解析得到参数。
  2. 开始调用下层逻辑。

例如一个关注动作触发的逻辑:

NewProxyPostFollowAction().Do()
//其中Do主要包含以下两个逻辑,对应两个方法
p.parseNum() //解析参数
p.startAction() //开始调用下层逻辑

Service

对于service层级的函数实现由如下规范:

同样由一个代理对象进行,完成以下三个或两个逻辑

当上层需要返回数据信息,则进行三个逻辑:

  1. 检查参数。
  2. 准备数据。
  3. 打包数据。

当上层不需要返回数据信息,则进行两个逻辑:

  1. 检查参数。
  2. 执行上层指定的动作。

例如关注动作在service层的逻辑属于第二类:

NewPostFollowActionFlow(...).Do()
//Do中包含以下两个逻辑
p.checkNum() //检查参数
p.publish() //执行动作

Models

对于models层的各个操作,没有像service和handler层针对前端发来的请求就行对应的处理,models层是面向于数据库的增删改查,不需要考虑和上层的交互。

而service层根据上层的需要来调用models层的不同代码请求数据库内的内容。

遇到的问题及对应解决方案

返回json数据的完整性和前端要求的一致性

由于数据库内的一对一、一对多、多对多关系是根据id进行映射,所以models层请求得到的字段并不包含前端所需要的直接数据,比如前端要求Comment结构中需要包含UserInfo,而我的Comment结构如下:

type Comment struct {
	Id         int64     `json:"id"`
	UserInfoId int64     `json:"-"` //用于一对多关系的id
	VideoId    int64     `json:"-"` //一对多,视频对评论
	User       UserInfo  `json:"user" gorm:"-"`
	Content    string    `json:"content"`
	CreatedAt  time.Time `json:"-"`
	CreateDate string    `json:"create_date" gorm:"-"`
}

很明显,为了与数据库中设计的表一一对应,在原数据的基础上加了几个字段,且在gorm屏蔽了User字段,所以service调用models层得到是Comment数据中User字段还未被填充,还需再填充这部分内容,好在由对应的UserId,故可以正确填充该字段。

为了重用以及不破坏代码的一致性,将填充逻辑写入util包内,比如以上的字段填充函数,同时前端要求的日期格式也能够按要求设置:

func FillCommentListFields(comments *[]*models.Comment) error {
	size := len(*comments)
	if comments == nil || size == 0 {
		return errors.New("util.FillCommentListFields comments为空")
	}
	dao := models.NewUserInfoDAO()
	for _, v := range *comments {
		_ = dao.QueryUserInfoById(v.UserInfoId, &v.User) //填充这条评论的作者信息
		v.CreateDate = v.CreatedAt.Format("1-2")         //转为前端要求的日期格式
	}
	return nil
}

这里举了Comment这一个例子,其他的Video也是同理。

is_favorite和is_follow字段的更新

每次为视频点赞都会在数据库的user_favor_videos表中加入用户的id和视频的id,很明显is_favorite字段是针对每个用户来判断的,而我所设计的数据库中的videos表也是包含这个字段的,但这个字段很明显不能直接进行复用,而是需要每次判断用户和此视频的关系来重新更新。

这个更新过程放入util包的填充函数中即可,为了点赞过程的迅速响应,我采取了nosql的方式存储了这个点赞的映射,也就是userId和videoId的映射,也就是用nosql代替了这个中间表的功效。

具体代码逻辑在cache包内。

视频的保存和封面的切片

视频的保存

在本地建立static文件夹存储视频和封面图片。

具体逻辑如下:

  1. 检查视频格式
  2. 根据userId和该作者发布的视频数量产生唯一的名称,如id为1的用户发布了0个视频,那么本次上传的名称为1-0.mp4
  3. 截取第一帧画面作为封面
  4. 保存视频基本信息到数据库(包括视频链接和封面链接

封面的截取

使用ffmpeg调用命令行对视频进行截取。

设计ffmpeg请求类Video2Image,通过对它内部的参数设置来构造对应的命令行字符串。具体请看util包内的ffmpeg.go的实现。

由于我设计的命令请求字符串是直接的一行字符串,而go语言exec包里面的Command函数执行所需的仅仅是一个个参数。

所以此处我想到用cgo直接调用 system(args)来解决。

代码如下:

//#include <stdlib.h>
//int startCmd(const char* cmd){
//	  return system(cmd);
//}
import "C"

func (v *Video2Image) ExecCommand(cmd string) error {
	if v.debug {
		log.Println(cmd)
	}
	cCmd := C.CString(cmd)
	defer C.free(unsafe.Pointer(cCmd))
	status := C.startCmd(cCmd)
	if status != 0 {
		return errors.New("视频切截图失败")
	}
	return nil
}

可改进的地方

  1. 写到后面发现很多mysql的数据可以用redis优化。
  2. 很多执行逻辑可以通过并行优化。
  3. 路由分组可以更为详实。
  4. ...

项目运行

本项目运行不需要手动建表,项目启动后会自动建表。

运行所需环境

  • mysql 5.7及以上
  • redis 5.0.14及以上
  • ffmepg(已放入lib自带,用于对视频切片得到封面
  • 需要gcc环境(主要用于cgo,windows请将mingw-w64设置到环境变量

运行需要更改配置

进入config目录更改对应的mysql、redis、server、path信息。

  • mysql:mysql相关的配置信息
  • redis:redis相关配置信息
  • server:当前服务器(当前启动的机器)的配置信息,用于生成对应的视频和图片链接
  • path:其中ffmpeg_path为lib里的文件路径,static_source_path为本项目的static目录,这里请根据本地的绝对路径进行更改

完成config配置文件的更改后,需要再更改conf.go里的解析文件路径为config.toml文件的绝对路径,内容如下:

if _, err := toml.DecodeFile("你的绝对路径\\config.toml", &Info); err != nil {
		panic(err)
	}

运行所需命令

cd .\byte_douyin_project\
go run main.go