1. 定义视频实体类
先切换分支,在新分支进行开发
git checkout -b feature/feed-publish
在model/entity目录下创建video.go文件
package entity
import "gorm.io/gorm"
type TableVideo struct {
gorm.Model
AuthorId uint64 // 视频作者id
Title string // 视频标题
PlayUrl string // 播放地址
CoverUrl string // 封面地址
}
func (tableVideo TableVideo) TableName() string {
return "videos"
}
2. 视频表的dao操作函数
在model/dao目录下新建videoDao.go文件
package dao
import (
"faker-douyin/global"
"faker-douyin/model/entity"
"time"
)
// GetVideosByAuthorId 根据作者id获取作者所有视频信息
func GetVideosByAuthorId(authorId uint64) ([]entity.TableVideo, error) {
var videos []entity.TableVideo
if err := Db.Where("authorId = ?", authorId).Find(&videos).Error; err != nil {
return videos, err
}
return videos, nil
}
// GetVideoById 根据视频id获取视频信息
func GetVideoById(videoId uint64) (entity.TableVideo, error) {
var video entity.TableVideo
if err := Db.Find(&video, videoId).Error; err != nil {
return video, err
}
return video, nil
}
// GetVideosByLastTime 依据一个时间,来获取这个时间之前的一些视频
func GetVideosByLastTime(lastTime time.Time) ([]entity.TableVideo, error) {
var videos []entity.TableVideo
if err := Db.Where("created_at < ?", lastTime).Order("created_at desc").Limit(global.VideoCount).Find(&videos).Error; err != nil {
return videos, err
}
return videos, nil
}
// InsertTableVideo 插入视频数据
func InsertTableVideo(title string, videoName string, imageName string, authorId uint64) error {
var video entity.TableVideo
video.Title = title
video.PlayUrl = global.PlayUrlPrefix + videoName + ".mp4"
video.CoverUrl = global.CoverUrlPrefix + imageName + ".jpg"
video.AuthorId = authorId
if err := Db.Save(&video).Error; err != nil {
return err
}
return nil
}
迁移数据表结构
Db.AutoMigrate(&entity.TableUser{}, &entity.TableVideo{})
3. videoDao单元测试
在model/dao目录下新建videoDao_test.go文件
package dao
import (
"faker-douyin/global"
"fmt"
"testing"
"time"
)
func TestGetVideosByAuthorId(t *testing.T) {
global.LoadConfig()
Init()
data, err := GetVideosByAuthorId(2)
if err != nil {
t.Error(err)
}
for _, video := range data {
fmt.Println(video)
}
}
func TestGetVideoById(t *testing.T) {
global.LoadConfig()
Init()
data, err := GetVideoById(1)
if err != nil {
t.Error(err)
}
fmt.Println(data)
}
func TestGetVideosByLastTime(t *testing.T) {
global.LoadConfig()
Init()
data, err := GetVideosByLastTime(time.Now())
if err != nil {
t.Error(err)
}
for _, video := range data {
fmt.Println(video)
}
}
func TestInsertTableVideo(t *testing.T) {
global.LoadConfig()
Init()
err := InsertTableVideo("测试标题", "测试视频名", "测试封面名", 2)
if err != nil {
t.Error(err)
}
}
进行单元测试
cd /faker-douyin/model/dao
go test
3. 实现ftp文件传输
官方推荐的ftp第三方库github.com/dutchcoders…
- 下载
go get -u github.com/jlaffaye/ftp
- 在
utils目录下新建ftp.go文件
package utils
import (
"faker-douyin/global"
"fmt"
"github.com/jlaffaye/ftp"
"io"
"log"
"time"
)
var MyFtp *ftp.ServerConn
func InitFtp() {
dsn := global.Config.Ftp.Host + ":" + global.Config.Ftp.Port
fmt.Println(dsn)
var err error
MyFtp, err = ftp.Dial(dsn, ftp.DialWithTimeout(5*time.Second))
if err != nil {
log.Fatal(err)
}
fmt.Println("ftp服务器链接成功")
err = MyFtp.Login(global.Config.Ftp.User, global.Config.Ftp.Password)
if err != nil {
log.Fatal(err)
}
fmt.Println("登陆ftp服务器成功")
//Linux小知识:用户登陆时所处目录/home/$username
//cwd, err := MyFtp.CurrentDir()
//if err != nil {
// fmt.Println("获取当前目录失败")
//}
//fmt.Println(cwd)
go keepAlive()
}
func keepAlive() {
time.Sleep(time.Duration(global.Config.Ftp.HeartbeatTime) * time.Second)
err := MyFtp.NoOp()
if err != nil {
log.Fatal("维持ftp长链接失败")
}
}
// VideoFTP
// 通过ftp将视频传入服务器
func VideoFTP(file io.Reader, videoName string) error {
//不能转到video相对路线下,因为再一次使用该函数会跳转相对路径失败
err := MyFtp.ChangeDir("/home/ftpuser/videos")
if err != nil {
log.Println("转到路径videos失败!!!")
} else {
log.Println("转到路径videos成功!!!")
}
err = MyFtp.Stor(videoName+".mp4", file)
if err != nil {
log.Println("上传视频失败!!!!!")
return err
}
log.Println("上传视频成功!!!!!")
return nil
}
// ImageFTP
// 将图片传入FTP服务器中,但是这里要注意图片的格式随着名字一起给,同时调用时需要自己结束流
func ImageFTP(file io.Reader, imageName string) error {
//转到video相对路线下
err := MyFtp.ChangeDir("images")
if err != nil {
log.Println("转到路径images失败!!!")
return err
}
log.Println("转到路径images成功!!!")
if err = MyFtp.Stor(imageName, file); err != nil {
log.Println("上传图片失败!!!!!")
return err
}
log.Println("上传图片成功!!!!!")
return nil
}
- 测试上传图片
登陆服务器,创建保存视频和图片的目录/home/ftpuser/videos以及/home/ftpuser/images,再赋予当前ftpuser在这两个目录下创建文件的权限
chown -R ftpuser.ftpuser /home/ftpuser/images
chown -R ftpuser.ftpuser /home/ftpuser/videos
准备好一个图片WechatIMG56.jpeg,放在项目根目录,修改mian.go文件
package main
import (
"faker-douyin/global"
"faker-douyin/model/dao"
"faker-douyin/utils"
"fmt"
"os"
)
func main() {
global.LoadConfig()
dao.Init()
utils.InitFtp()
//gin.SetMode(global.Config.Server.AppMode)
//r := gin.Default()
//router.InitRouter(r)
//r.Run(global.Config.Server.HttpPort)
file, err := os.Open("./WechatIMG56.jpeg")
if err != nil {
fmt.Println(err)
}
err = utils.ImageFTP(file, "测试")
if err != nil {
fmt.Println(err)
}
}
4. ffmpeg视频编解码实现视频截图
通过ssh连接,发送shell命令调用ffmpeg解码功能实现视频截图,在utils目录下新建ffmpeg.go文件,go使用ssh登录用golang.org/x/crypto/ss…这个库
package utils
import (
"faker-douyin/global"
"fmt"
"golang.org/x/crypto/ssh"
"log"
"time"
)
type Ffmsg struct {
VideoName string
ImageName string
}
var ClientSSH *ssh.Client
var Ffchan chan Ffmsg
// InitSSH 建立SSH客户端,但是会不会超时导致无法链接,这个需要做一些措施
func InitSSH() {
var err error
//创建ssh登陆配置
SshConfig := &ssh.ClientConfig{
Timeout: 5 * time.Second, //ssh 连接time out 时间一秒钟, 如果ssh验证错误 会在一秒内返回
User: global.Config.Ssh.User,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //这个可以, 但是不够安全
//HostKeyCallback: hostKeyCallBackFunc(h.Host),
}
if global.Config.Ssh.TypeSsh == "password" {
SshConfig.Auth = []ssh.AuthMethod{ssh.Password(global.Config.Ssh.Password)}
}
//dial 获取ssh client
addr := fmt.Sprintf("%s:%s", global.Config.Ssh.Host, global.Config.Ssh.Port)
ClientSSH, err = ssh.Dial("tcp", addr, SshConfig)
if err != nil {
log.Fatal("创建ssh client 失败", err)
}
log.Printf("获取到客户端:%v", ClientSSH)
//建立通道,作为队列使用,并且确立缓冲区大小
Ffchan = make(chan Ffmsg, global.MaxMsgCount)
//建立携程用于派遣
go dispatcher()
go SshKeepAlive()
}
// 通过增加携程,将获取的信息进行派遣,当信息处理失败之后,还会将处理方式放入通道形成的队列中
func dispatcher() {
for ffmsg := range Ffchan {
go func(f Ffmsg) {
err := Ffmpeg(f.VideoName, f.ImageName)
if err != nil {
Ffchan <- f
log.Fatal("派遣失败:重新派遣")
}
log.Printf("视频%v截图处理成功", f.VideoName)
}(ffmsg)
}
}
// Ffmpeg 通过远程调用ffmpeg命令来创建视频截图
func Ffmpeg(videoName string, imageName string) error {
session, err := ClientSSH.NewSession()
if err != nil {
log.Fatal("创建ssh session 失败", err)
}
defer session.Close()
//执行远程命令 ffmpeg -ss 00:00:01 -i /home/ftpuser/videos/1.mp4 -vframes 1 /home/ftpuser/images/4.jpg
combo, err := session.CombinedOutput("ls;/usr/local/ffmpeg/bin/ffmpeg -ss 00:00:01 -i /home/ftpuser/videos/" + videoName + ".mp4 -vframes 1 /home/ftpuser/images/" + imageName + ".jpg")
if err != nil {
//log.Fatal("远程执行cmd 失败", err)
log.Fatal("命令输出:", string(combo))
return err
}
//fmt.Println("命令输出:", string(combo))
return nil
}
// SshKeepAlive 维持长链接
func SshKeepAlive() {
time.Sleep(time.Duration(global.SSHHeartbeatTime) * time.Second)
session, _ := ClientSSH.NewSession()
session.Close()
}
- 测试ffmpeg功能 先上传视频到服务器
修改
main.go文件,向管道发送信息
package main
import (
"faker-douyin/global"
"faker-douyin/model/dao"
"faker-douyin/router"
"faker-douyin/utils"
"github.com/gin-gonic/gin"
)
func main() {
global.LoadConfig()
dao.Init()
utils.InitFtp()
utils.InitSSH()
utils.Ffchan <- utils.Ffmsg{
VideoName: "测试",
ImageName: "测试",
}
gin.SetMode(global.Config.Server.AppMode)
r := gin.Default()
router.InitRouter(r)
r.Run(global.Config.Server.HttpPort)
}
5. videoService定义与实现
在service目录下新建videoService.go文件
package service
import (
"faker-douyin/model/entity"
"mime/multipart"
"time"
)
type VideoService interface {
// Feed
// 通过传入时间戳,当前用户的id,返回对应的视频切片数组,以及视频数组中最早的发布时间
Feed(lastTime time.Time, userId uint64) ([]entity.TableVideo, time.Time, error)
// GetVideo
// 传入视频id获得对应的视频对象
GetVideo(videoId int64, userId int64) (entity.TableVideo, error)
// Publish
// 将传入的视频流保存在文件服务器中,并存储在mysql表中
// 5.23 加入title
Publish(data *multipart.FileHeader, userId int64, title string) error
// List
// 通过userId来查询对应用户发布的视频,并返回对应的视频切片数组
List(userId int64, curId int64) ([]entity.TableVideo, error)
// GetVideoIdList
// 通过一个作者id,返回该用户发布的视频id切片数组
GetVideoIdList(userId int64) ([]int64, error)
}
再在service目录下新建videoServiceImpl.go文件,前端只传递视频和视频标题以及token,视频名和图片名由后端uuid生成github.com/google/uuid
package service
import (
"faker-douyin/model/dao"
"faker-douyin/model/entity"
"faker-douyin/utils"
"github.com/google/uuid"
"log"
"mime/multipart"
"time"
)
type VideoServiceImpl struct {
}
func (v VideoServiceImpl) Feed(lastTime time.Time, userId uint64) ([]entity.TableVideo, time.Time, error) {
//TODO implement me
panic("implement me")
}
func (v VideoServiceImpl) GetVideo(videoId int64, userId uint64) (entity.TableVideo, error) {
//TODO implement me
panic("implement me")
}
func (v VideoServiceImpl) Publish(data *multipart.FileHeader, userId uint64, title string) error {
file, err := data.Open()
defer func(file multipart.File) {
err := file.Close()
if err != nil {
}
}(file)
if err != nil {
log.Println("open upload file failed", err)
return err
}
// 上传视频
videoName := uuid.NewString()
err = utils.VideoFTP(file, videoName)
if err != nil {
log.Println("上传视频失败:", err)
return err
}
// 调用ffmpeg生成截图
imageName := uuid.NewString()
utils.Ffchan <- utils.Ffmsg{
VideoName: videoName,
ImageName: imageName,
}
err = dao.InsertTableVideo(title, videoName, imageName, userId)
if err != nil {
log.Println("新增视频数据失败:", err)
return err
}
return nil
}
func (v VideoServiceImpl) List(userId uint64, curId uint64) ([]entity.TableVideo, error) {
//TODO implement me
panic("implement me")
}
func (v VideoServiceImpl) GetVideoIdList(userId uint64) ([]int64, error) {
//TODO implement me
panic("implement me")
}
6. videoController
在api/v1目录下新建videoController.go文件
package v1
import (
"faker-douyin/model/common"
"faker-douyin/service"
"github.com/gin-gonic/gin"
"log"
"strconv"
)
func Publish(c *gin.Context) {
// userId在jwt中间件中已被存入Context
userId, err := strconv.ParseInt(c.GetString("userId"), 10, 64)
if err != nil {
log.Println("parse userId failed", err)
}
title := c.PostForm("title")
file, err := c.FormFile("file")
if err != nil {
log.Println("服务端接收视频文件失败", err)
common.FailWithMessage(err.Error(), c)
return
}
vsi := service.VideoServiceImpl{}
err = vsi.Publish(file, uint64(userId), title)
if err != nil {
common.FailWithMessage(err.Error(), c)
return
}
common.OkWithMessage("upload success", c)
}
7. 注册路由
修改/router/router.go文件
apiRouter.POST("/video/publish/", jwt.Auth(), v1.Publish)
8. 测试接口
未登陆
上传成功
9. 上传、合并代码
git add .
git commit -m "ftp upload ssh remote ffmpeg video upload feature"
git push --set-upstream origin feature/feed-publish
pull request and merge request
10. 遇到的问题
- 通过ftp上传视频时,原作者每次都更换到相对目录,应该切换至绝对路径
- 原作者生成token的函数依赖了service层的代码,我将生成token的函数放在
utils目录下,导致出现循环依赖,后将生成token的函数入参变为*entity.TableUser,原用户接口的调用也跟着换了入参