这是我参与「第五届青训营 」伴学笔记创作活动的第 10 天
前言
这个笔记系列是对我开发抖声后端的一个记录。会在活动快要结束的时候发布吧应该。
我会写好每个部分的开发过程。但由于是边开发编写,很有可能会出现现在写的在后面全部被推翻了的情况。所以各位也请选择性的观看。
部分逻辑可能写出来的很糟糕,这也是我第一个多人合作和考虑编码流程规范的一个项目,请大家见谅。
仓库地址:tiktok-go
API规范:极简版抖音
开发环境配置
由于基础的环境不是我配置的,所以我没法讲解这一部分的内容。简单来说我们使用了docker来搭建我们的开发环境,使用postgres来提供数据的持久化。这两个docker使用docker-compose来管理。开发环境使用了air做热更新。
具体的配置文件在仓库中可以看到:docker-compose.yml和Dockerfile_air。
基础接口
user相关
这一部分也不是我写的。这部分是大佬写的(后面我也就跟着大佬照葫芦画瓢)。我简单的讲一讲代码和文件结构吧。我开始写的时候,结构如下。主要的结构是simple-demo提供的。
.
├── controller // handler
│ ├── comment.go
│ ├── common.go
│ ├── demo_data.go
│ ├── favorite.go
│ ├── feed.go
│ ├── message.go
│ ├── publish.go
│ ├── relation.go
│ └── user.go
├── docker-compose.yml
├── Dockerfile_air
├── main.go
├── public // upload video
│ └── bear.mp4
├── README.md
├── repository // postgres 数据库相关
│ ├── db_init.go // 数据库初始化操作
│ └── user.go // user表结构与操作user表接口
├── router.go
├── service
│ ├── message.go
│ └── user
│ └── user.go // user相关服务封装
├── test // test 相关
│ ├── base_api_test.go
│ ├── common.go
│ ├── interact_api_test.go
│ ├── message_server_test.go
│ └── social_api_test.go
└── tmp
└── main
controller中是我们的http handler。我们的http请求会在这里处理,下面的部分文件分别对应了不同的接口模块。repository是数据库相关部分。其中包含了数据库的初始化(在main中调用),表结构和表操作(不同的表分开写)。user的表结构和相关操作已经被大佬创建好了。
service下的user模块是大佬新增的。用来封装对user的操作为指定的功能。以前我没有这么组织过代码,学习了。
好,我开始写的时候,就是这个代码结构了。
video相关
首先要处理video的存储。看了一遍项目后,视频上传后保存在public目录下,而提供的video结构体是:
type Video struct {
Id int64 `json:"id,omitempty"`
Author User `json:"author"`
PlayUrl string `json:"play_url,omitempty"`
CoverUrl string `json:"cover_url,omitempty"`
FavoriteCount int64 `json:"favorite_count,omitempty"`
CommentCount int64 `json:"comment_count,omitempty"`
IsFavorite bool `json:"is_favorite,omitempty"`
}
一共7个字段。可以看到视频保存的是一个url,还需要一个cover。
使用数据库来按照这个结构来存储video。
数据库没有细考虑,后面实现的功能多了,表与表之间的关系还需要重新调整,代码也还需要重新写。为了暂时实现功能,这里就只看做我们user和video两个表(虽然也没写上约束啥的)。后面写了favorite和comment再说。
数据库videos表结构如下:
type Video struct {
Id int64 `gorm:"primary_key;AUTO_INCREMENT" json:"id,omitempty"`
Author string `json:"author"`
PlayUrl string `json:"play_url,omitempty"`
CoverUrl string `json:"cover_url,omitempty"`
FavoriteCount int64 `json:"favorite_count,omitempty"`
CommentCount int64 `json:"comment_count,omitempty"`
IsFavorite bool `json:"is_favorite,omitempty"`
CreatedAt int64 `json:"created_at,omitempty"`
UpdatedAt int64 `json:"updated_at,omitempty"`
DeleteAt int64 `json:"delete_at,omitempty"`
}
在repository目录下新建video.go文件包为repository。
下面创建video的DAO:
type VideoDao struct {
db *gorm.DB
}
var (
videoDao *VideoDao
videoOnce sync.Once
)
func NewVideoDaoInstance() *VideoDao {
videoOnce.Do(func() {
videoDao = &VideoDao{
db: db,
}
})
return videoDao
}
sync.Once是用来做初始化操作的。这样NewVideoDaoInstance只有在第一次调用的时候会初始化videoDAO,后面就直接返回之前创建好的DAO了。
下面完成部分数据库交互操作。
func (v *VideoDao) CreateVideo(video *Video) error {
res := v.db.Create(video)
return res.Error
}
func (v *VideoDao) GetVideoByID(id int64) (*Video, error) {
var video Video
res := v.db.Where("id = ?", id).First(&video)
return &video, res.Error
}
func (v *VideoDao) GetVideosByAuthor(username string) (*[]Video, error) {
var videos []Video
res := v.db.Where("author = ?", username).Order("created_at DESC").Find(&videos)
return &videos, res.Error
}
func (v *VideoDao) GetVideoList(timeStamp int64) (*[]Video, error) {
var video []Video
res := v.db.Where("created_at <= ?", timeStamp).Order("created_at DESC").Limit(30).Find(&video)
return &video, res.Error
}
由于api中没有写很多的功能啊,所以这里也就只写了增和查。这边的语法就去看gorm就行了。关于GetVideoList,api要求返回数量最大为30而且由服务端控制,这里就给他limit了。
表结构和基本数据库交互完成后,在数据库初始化中注册这个表。
func InitDB(dsn string) error {
...
err = db.AutoMigrate(&Video{})
if err != nil {
return err
}
return nil
}
AutoMigrate会自动的帮我们创建表和更新表结构。
在service创建video包,来写我们video向上提供的服务。
在提供的api中,一共有三个接口和我们video有关,分别是视频流接口、投稿接口和发布列表接口。
这里我依次对应给出三个服务:获取所有视频、创建视频、通过用户名获取视频(后面证明应该用id而不是用户名)。
func PublishVideo(video *repository.Video) error {
return repository.NewVideoDaoInstance().CreateVideo(video)
}
func GetVideosByUsername(username string) (*[]repository.Video, error) {
return repository.NewVideoDaoInstance().GetVideosByAuthor(username)
}
func GetStreamFeed(timeStamp int64) (*[]repository.Video, error) {
return repository.NewVideoDaoInstance().GetVideoList(timeStamp)
}
接下来完成handler的流程处理。
首先我们来完成feed.go中的Feed函数:
func Feed(c *gin.Context) {
// gin will give a default timestamp
timeStamp, err := strconv.ParseInt(c.Query("latest_time"), 10, 64)
if err != nil {
c.JSON(http.StatusOK, FeedResponse{
Response: Response{
StatusCode: 1,
StatusMsg: "Invaild timeStamp.",
},
VideoList: nil,
NextTime: time.Now().Unix(),
})
return
}
videos, err := service_video.GetStreamFeed(timeStamp)
if err != nil {
c.JSON(http.StatusOK, FeedResponse{
Response: Response{
StatusCode: 2,
StatusMsg: fmt.Sprintf("Fetch video list faild. Error: %v", err),
},
VideoList: nil,
NextTime: time.Now().Unix(),
})
return
}
// maybe preallocate enough memory will better
var respVideoList []Video
for _, video := range *videos {
respVideoList = append(respVideoList, *RepoVideoToCon(&video))
}
c.JSON(http.StatusOK, FeedResponse{
Response: Response{StatusCode: 0},
VideoList: respVideoList,
NextTime: time.Now().Unix(),
})
}
由于不登录的用户也能查看视频,而且api中没有提供user相关的信息,所以这里不需要做user相关的校验工作。
请求这个接口的时候,会有一个默认的时间戳,所以可以正常的获取这个latest_time。避免人为的传入错误的时间戳,这里做一个对时间戳格式的校验,其实就是看看能不能转换成int64(因为我们数据库中存的CreatedAt啥的都是以int64作为时间的)。
校验成功后,就可以从数据库拉取存在的video了。
这里遇到一个问题,在上文中我们提到了两个都叫video的结构体。一个是我们response中用的video,一个是我们数据库中用的video。这两个结构不相同,我们需要做一个转换。
func RepoVideoToCon(video *repository.Video) *Video {
author, _ := repository.NewUserDaoInstance().GetUserByName(video.Author)
return &Video{
Id: video.Id,
Author: *RepoUserToCon(author),
PlayUrl: video.PlayUrl,
CoverUrl: video.CoverUrl,
FavoriteCount: video.FavoriteCount,
CommentCount: video.CommentCount,
// TODO: favourite list hasnt been develop yet
IsFavorite: video.IsFavorite,
}
通过这个函数我们来将数据库的video结构体转换为响应中的video。
下面是publish部分。发布列表和投稿接口的handler都在这个文件中。
先来实现pulishList,这个简单点。
// PublishList all users have same publish video list
func PublishList(c *gin.Context) {
// What the describe mean? is not resonable
// I will follow this: list all videos that this user published
var (
id int64
err error
videos *[]repository.Video
)
token := c.Query("token")
if id, err = strconv.ParseInt(c.Query("user_id"), 10, 64); err != nil {
c.JSON(http.StatusOK, VideoListResponse{
...
})
return
}
u, exist := service_user.GetUserByToken(token)
if !exist || u.Id != id {
c.JSON(http.StatusOK, VideoListResponse{
...
})
return
}
if videos, err = service_video.GetVideosByUsername(u.Name); err != nil {
c.JSON(http.StatusOK, VideoListResponse{
...
})
return
}
var respVideoList []Video
for _, video := range *videos {
respVideoList = append(respVideoList, *RepoVideoToCon(&video))
}
c.JSON(http.StatusOK, VideoListResponse{
...
})
}
省略了一些意义不大的代码。
上来这个注释就没看懂。所有的用户拥有一样的发布列表?我觉得这样并不合理,反正也不是要给甲方的,这里我就按照自己认为正确的来写了:返回当前用户发布的视频。
这个api提供一个token一个user id。
首先判断给的id是不是合法的id,然后获取token对应的用户,比较用户的id和提供id是否一致。用户存在且一致验证通过。
随后使用查询到的username来在video表中查询相关的视频记录。最后返回。
最后是我们的投稿接口。
由于视频数据是url。这个host名整了好久没有想出来有什么好办法来获取。我看gin的context里面也没有请求的url。最后还是选择了在环境变量里面写host(在docker-compose.yml配置)。
这个投稿接口我们改改就可以用了。
func Publish(c *gin.Context) {
// TODO: file path tidy
token := c.PostForm("token")
if _, exist := service_user.GetUserByToken(token); !exist {
c.JSON(http.StatusOK, Response{StatusCode: 1, StatusMsg: "User doesn't exist"})
return
}
... // 保存文件到public
// gen cover
var (
dotPos int
coverFile string
coverUrl string
)
dotPos = strings.LastIndex(data.Filename, ".")
if dotPos != -1 {
coverFile = string(data.Filename[:dotPos])
}
coverFile += ".jpg"
finalCover := fmt.Sprintf("%d_%s", user.Id, coverFile)
coverErr := utils.GenVideoCover(saveFile, filepath.Join("./public/", finalCover))
host := os.Getenv("Host")
videoUrl := host + fmt.Sprintf("/static/%s", finalName)
if coverErr != nil {
coverUrl = DemoVideos[0].CoverUrl
} else {
coverUrl = host + fmt.Sprintf("/static/%s", finalCover)
}
// add database
video := &repository.Video{
Author: user.Name,
PlayUrl: videoUrl,
CoverUrl: coverUrl,
}
// need remove the file which insert faild?
// if err = service_video.PublishVideo(video); err != nil {
// upload file can overwrite, not need to processing require
// }
service_video.PublishVideo(video)
c.JSON(http.StatusOK, Response{
StatusCode: 0,
StatusMsg: finalName + " uploaded successfully",
})
}
这里隐藏了原本就有的代码。首先验证用户(这几行不是我写的)。
然后文件保存到指定目录下。
然后我们要来处理这个cover了。从视频生成一个封面。网上有现成的轮子,用的ffmpeg。直接拿过来用。
要用ffmpeg的话,就需要把ffmpeg装进环境里了。这边使用静态ffmpeg包。
# the wget url is the package in my OBS, the origin url is: https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
wget https://lanpesk-package-proxy.obs.cn-north-4.myhuaweicloud.com/ffmpeg-release-amd64-static.tar.xz && \
tar -xvJf ffmpeg-release-amd64-static.tar.xz -C /tmp && \
rm -f ffmpeg-release-amd64-static.tar.xz && \
# a soft link for ffmpeg, the ffmpeg dir name maybe different, if you use ffmpeg in other version, please check this
ln -s /tmp/ffmpeg-5.1.1-amd64-static/ffmpeg /usr/bin/ffmpeg
由于下载速度感人,为了加速部署,我把这个静态包下到了自己的OBS里面。
还需要ffmpeg的go绑定:
go get "github.com/u2takey/ffmpeg-go"
go get "github.com/disintegration/imaging"
这个算是工具吧,所以我把它放到了utils里面。轮子代码这里就不放了。
这样,我们在保存好视频和生成完封面后,会构造这两个资源的url。接下来只需要调用提供的创建视频的服务来把这个视频存到数据库即可。
这里原本像写成强异常保证的。但好像文件可以被覆盖,好像也没什么需要删除的需求(有可能造成存储浪费啊,这个你不删不久一直在这了吗?)。所以这里就没看数据库操作的err了。
最后文件结构如下:
.
├── controller
│ └── ...
├── public
│ └── ...
├── repository
│ ├── db_init.go
│ ├── user.go
│ └── video.go
├── service
│ ├── message.go
│ ├── user
│ │ └── user.go
│ └── video
│ └── video.go
├── test
│ └── ...
└── utils
└── cover.go