faker-douyin-6. 视频上传接口

155 阅读7分钟

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)
   }
}

WechatIMG57.jpeg

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功能 先上传视频到服务器

WechatIMG58.jpeg 修改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. 测试接口

未登陆

WechatIMG59.jpeg 上传成功

WechatIMG60.jpeg

WechatIMG61.jpeg

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

WechatIMG62.jpeg

10. 遇到的问题

  • 通过ftp上传视频时,原作者每次都更换到相对目录,应该切换至绝对路径
  • 原作者生成token的函数依赖了service层的代码,我将生成token的函数放在utils目录下,导致出现循环依赖,后将生成token的函数入参变为*entity.TableUser,原用户接口的调用也跟着换了入参