流媒体网站开发(二)

一、视频服务搭建

1.1 准备工作

首先,新建streamserver目录,然后定义main.go文件。

package streamserver

import (
	"github.com/julienschmidt/httprouter"
	"net/http"
)

func main() {
	router := RegisterHandler()
	newRouter := NewMiddleWareHandler(router)
	http.ListenAndServe(":9000", newRouter)
}

func RegisterHandler() *httprouter.Router {
	router := httprouter.New()
	//router.POST("/", RegistHandler)
	return router
}

func RegistHandler(w http.ResponseWriter, r *http.Request, p httprouter.Params) {

}

// 中间件方法,该方法对http.Router进行增强处理
// 因http.Router实现http.Handler接口,因此,该方法也返回一个实现http.Handler接口的对象
func NewMiddleWareHandler(r *httprouter.Router) http.Handler {
	// 创建中间件Handler对象
	m := &MiddleWareHandler{}
	// 把router对象传入到中间件里面
	m.router = r
	// 返回增强处理后的Handler
	return m
}

// 定义中间件结构体,该结构体实现http.Handler接口
type MiddleWareHandler struct {
	router *httprouter.Router
}

// 实现http.Handler接口的ServeHTTP方法
func (m MiddleWareHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// TODO 显示同时观看视频的用户数

	// 保留原有处理请求的功能
	m.router.ServeHTTP(w, r)
}

2.2 视频中间件

视频中间件用于控制播放视频的连接数。这里我们通过管道对用户连接数进行控制。

第一步:在streamserver目录下新建limiter.go文件,然后定义一个结构体,该结构体记录了最大连接数和当前连接数。并且当前连接数使用管道进行控制。

type ConnLimiter struct {
	maxConn int // 最大连接数
	bucket chan int // 使用管道控制当前连接数
}

第二步:定义一个方法,该方法返回ConnLimiter对象。

func NewConnLimiter(maxSize int) *ConnLimiter {
	return &ConnLimiter{
		maxSize,
		make(chan int, maxSize),
	}
}

第三步:修改中间件结构体,增加ConnLimiter属性。

type MiddleWareHandler struct {
	router *httprouter.Router
	connLimiter *ConnLimiter // 连接数限制
}

第四步:NewMiddleWareHandler方法增加一个参数,用于记录最大连接数。

func main() {
	router := RegisterHandler()
	newRouter := NewMiddleWareHandler(router, 2)
	http.ListenAndServe(":9000", newRouter)
}

func NewMiddleWareHandler(r *httprouter.Router, maxsize int) http.Handler {
	// 创建中间件Handler对象
	m := &MiddleWareHandler{}
	// 把router对象传入到中间件里面
	m.router = r
	// 初始化connLimiter
	m.connLimiter = NewConnLimiter(maxsize)
	// 返回增强处理后的Handler
	return m
}

第五步:修改main方法,创建NewMiddleWareHandler方法时候指定最大连接数。

func main() {
	router := RegisterHandler()
	newRouter := NewMiddleWareHandler(router, 2)
	http.ListenAndServe(":9000", newRouter)
}

第六步:在ServeHTTP方法中实现访问用户数的控制。

func (m MiddleWareHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// 判断当前连接数是否超过最大连接数的限制,如果没有超过,则正常访问;否则返回失败原因
	if !m.connLimiter.GetConn() {
		sendErrorResponse(w, http.StatusTooManyRequests, "已超过最大连接数!")
		return
	}
	// 保留原有处理请求的功能
	m.router.ServeHTTP(w, r)
	// 释放连接
	defer m.connLimiter.ReleaseConn()
}

第七步:新建response.go文件,定义sendErrorResponse方法,该方法用于向客户端输出错误信息。

package main

import (
"io"
"net/http"
)

// 发送错误响应
func sendErrorResponse(w http.ResponseWriter, sc int, errMsg string) {
	// 输出响应码
	w.WriteHeader(sc)
	// 向客户端输出错误消息
	io.WriteString(w, errMsg)
}

二、播放视频

2.1 准备工作

在项目根路径下新建videos文件夹,该文件夹存放要播放的视频文件。
在这里插入图片描述

2.2 功能实现

第一步:注册路由;

func RegisterHandler() *httprouter.Router {
	router := httprouter.New()
	router.GET("/videos/:vid-id", StreamHandler)
	return router
}

第二步:实现请求处理的方法;

func StreamHandler(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
	// 获取请求参数
	vid := p.ByName("vid-id")
	// 获取视频文件路径
	videoPath := VIDEO_DIR + vid
	// 打开视频文件
	video, err := os.Open(videoPath)
	if err != nil {
		sendErrorResponse(w, http.StatusInternalServerError, "视频不存在!")
		return
	}
	// 设置响应头
	w.Header().Set("Content-Type", "video/mp4")
	// 把视频输出给客户端
	// 参数三:播放文件的文件名,如果没有设置,则输出文件的名字是未知的
	// 参数四:响应客户端的当前时间
	// 参数五:视频文件
	http.ServeContent(w, r, "", time.Now(), video)
}

最后运行程序,然后在浏览器上输入localhost:9000/videos/1.mp4,运行效果如下图所示:
在这里插入图片描述

三、上传视频

3.1 页面搭建

在videos目录下新建一个upload.html文件,文件内容如下所示:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form action="http://localhost:9000/upload/ddd" method="post" enctype="multipart/form-data">
        <input type="file" name="file"/>
        <input type="submit" value="上传"/>
    </form>
</body>
</html>

3.2 路由配置

第一步:配置upload.html页面的路由;

router.GET("/testpage", TestPageHandler)

第二步:定义处理函数;

func TestPageHandler(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
	// 创建Template模版,该模版指向upload.html文件
	t, _ := template.ParseFiles("./videos/upload.html")
	// 把模版输出到浏览器上
	t.Execute(w, nil)
}

测试:
在这里插入图片描述

四、上传视频

4.1 配置上传视频的路由

第一步:配置路由;

router.POST("/upload/:vid-id", UploadHandler)

第二步:定义处理函数;

func UploadHandler(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
	// 设置上传文件的上限
	// http.MaxBytesReader方法用于限制body请求体的大小
	r.Body = http.MaxBytesReader(w, r.Body, MAX_UPLOAD_SIZE)
	// r.ParseMultipartForm方法用于将请求的主体作为multipart/form-data解析
	// 请求的整个主体都会被解析,得到的文件记录最多maxMemery字节保存在内存,
	// 其余部分保存在硬盘的temp文件里
	// 获取上传文件
	file, _, err := r.FormFile("file")
	if err != nil {
		sendErrorResponse(w, http.StatusBadRequest, "上传文件失败!")
		return
	}
	// 读取上传文件内容
	data, err := ioutil.ReadAll(file)
	if err != nil {
		sendErrorResponse(w, http.StatusInternalServerError, err.Error())
		return
	}
	// 把上传文件保存在videos目录下
	fileName := p.ByName("vid-id")
	err = ioutil.WriteFile(VIDEO_DIR + fileName, data, 0666)
	if err != nil {
		sendErrorResponse(w, http.StatusInternalServerError, err.Error())
		return
	}
	// 输出上传结果
	w.WriteHeader(http.StatusCreated)
	io.WriteString(w, "上传成功")
}

测试:
在这里插入图片描述
点击上传按钮后:
在这里插入图片描述

五、删除视频

删除视频的表video_del_rec。
在这里插入图片描述
这个表只有一个字段video_id,该字段记录了要删除的视频ID。

5.1 准备工作

scheduler模块负责维护定时任务,以及提供定时删除视频的功能。

第一步:在项目根路径下创建scheduler目录;

第二步:创建dbops子目录;

第三步:在dbops目录下新建conn.go文件,该文件可以从api模块下的conn.go文件拷贝过来即可,不需要重复编写;
在这里插入图片描述
第四步:在scheduler目录下新建response.go文件,该文件提供了向客户端发送响应的方法。

package main

import (
	"io"
	"net/http"
)

// 发送响应
func sendResponse(w http.ResponseWriter, sc int, msg string) {
	// 输出响应码
	w.WriteHeader(sc)
	// 向客户端输出错误消息
	io.WriteString(w, msg)
}

5.2 定义video_dao

在dbops目录下新建video_dao.go文件,该文件提供了操作video_del_rec表的相关方法。

package dbops

// 向video_del_rec表中添加待删除的视频id
func AddVideoDelRec(vid string) error {
	stmt, err := dbConn.Prepare("insert into video_del_rec values(?)")
	if err != nil {
		return err
	}
	defer stmt.Close()
	_, err = stmt.Exec(vid)
	if err != nil {
		return err
	}
	return nil
}

// 删除video_del_rec表记录
func DelVideoDelRec(vid string) error {
	stmt, err := dbConn.Prepare("delete from video_del_rec where video_id = ?")
	if err != nil {
		return err
	}
	defer stmt.Close()
	_, err = stmt.Exec(vid)
	if err != nil {
		return err
	}
	return nil
}

// 按照参数查询video_del_rec表的n条记录
func ReadVideoDelRec(n int) ([]string, error) {
	// 定义一个切片,存储查询到的所有video_id字段值
	var ids []string
	stmt, err := dbConn.Prepare("select video_id from video_del_rec limit ?")
	if err != nil {
		return ids, err
	}
	defer stmt.Close()
	rows, err := stmt.Query(n)
	if err != nil {
		return ids, err
	}
	for rows.Next() {
		var id string
		if err := rows.Scan(&id); err != nil {
			return ids, err
		}
		ids = append(ids, id)
	}
	return ids, nil
}

5.3 服务器

在scheduler目录下新建main.go文件,文件内容如下:

package main

import (
	"github.com/julienschmidt/httprouter"
	"net/http"
	"video_server_demo/scheduler/dbops"
)

func main() {
	// 创建router对象
	router := RegisterHandler()
	// 使用中间件判断当前请求是否是要登录后才能够访问
	// 启动服务
	http.ListenAndServe(":8989", router)
}

func RegisterHandler() *httprouter.Router {
	router := httprouter.New()
	router.GET("/video-delete-record/:vid-id", VideoDeleteRecordHandler)
	return router
}

func VideoDeleteRecordHandler(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
	// 获取vid-id参数
	vid := p.ByName("vid-id")
	if len(vid) == 0 {
		sendResponse(w, 400, "参数vid-id为空!")
		return
	}
	// 执行插入操作
	err := dbops.AddVideoDelRec(vid)
	if err != nil {
		sendResponse(w, 500, "数据库操作失败!")
		return
	}
	sendResponse(w, 200, "添加成功!")
}

上面代码配置了一个路由,该路由用于往video_del_del表添加记录。

5.4 删除视频的定时任务

在scheduler目录下新建taskrunner子目录,然后在taskrunner目录下新建defs.go和task.go文件。defs.go存储常量和类型变量的定义。task.go存储要执行的任务代码。

下面是defs.go文件的完整代码:

const (
	VIDEO_PATH = "./video/"
)

// 定义一个管道,用于存储待删除的视频id
type dataChan chan interface{}

在task.go文件中定义三个方法:

  • DeleteVideo:从磁盘上删除指定id的视频文件;
  • VideoClearDispatcher:从video_del_del表中读取要删除的视频id,然后先存储到管道中;
  • VideoClearExecutor:从管道中读取要删除的视频id,然后执行删除操作(先调用DeleteVideo方法删除磁盘上的视频文件,然后再删除video_del_del表的记录);

下面是task.go文件的完整代码:

package taskrunner

import (
	"errors"
	"os"
	"video_server_demo/scheduler/dbops"
	"sync"
)

// 根据id删除视频文件
func DeleteVideo(vid string) error {
	err := os.Remove(VIDEO_PATH + vid)
	return err
}

// 从数据库中一次性读取多条数据
func VideoClearDispatcher(dc dataChan) error {
	res, err := dbops.ReadVideoDelRec(3)
	if err != nil {
		return err
	}
	if len(res) == 0 {
		return errors.New("任务结束!")
	}
	for _, id := range res {
		dc <- id
	}
	return nil
}

// 删除视频的执行函数
func VideoClearExecutor(dc dataChan) error {
	// 定义一个map,用于记录错误消息,视频id作为key,err作为value
	errMap := &sync.Map{}
	var err error
	forloop:
	for {
		select {
			case id := <- dc:
				// 启动go程执行删除操作
				go func(vid interface{}){
					// 在磁盘上删除视频
					if err := DeleteVideo(vid.(string)); err != nil {
						// 将删除的异常原因记录到map中
						errMap.Store(vid, err)
						return
					}
					// 删除video_del_rec表记录
					if err := dbops.DelVideoDelRec(vid.(string)); err != nil {
						errMap.Store(vid, err)
						return
					}
				}(id)
			default:
				// 管道中的数据已经消费完,循环终止
				break forloop
		}
	}
	// 循环遍历errMap,将error异常返回
	errMap.Range(func(key, value interface{}) bool {
		// 如果回调函数返回false,代表循环结束,否则继续循环遍历map
		err = value.(error)
		return err == nil
	})
	return err
}

5.5 生产和消费状态切换

上面VideoClearDispatcher方法负责生产数据(待删除的视频ID),VideoClearExecutor方法负责消费数据。生产和消费的操作应该是轮流执行。

首先在defs.go文件中定义一个管道,用于存储待删除的视频id,并且定义两个常量,用于标识当前任务的状态。

const (
	...
	READY_TO_DISPATH = "c" // 任务状态:生产状
	READY_TO_EXECUTE = "e" // 任务状态:消费
	CLOSE = "cl" // 错误状态
)

// 通过管道记录生产、消费的状态
type controlChan chan string

// 定义一个函数类型,需要和生产和消费方法的结构相同
type fn func(dc dataChan) error

然后,在taskrunner目录下新建runner.go文件,负责生产数据、消费数据,以及生产和消费状态的切换。

package taskrunner

/*
	该结构体用于管理状生产数据、消费数据的状态
*/
type Runner struct {
	Controller controlChan // 用于状态管理的通道
	Error      controlChan // 管理异常的通道
	Dispatcher fn          // 生产数据函数
	Executor   fn          // 消费数据函数
	ch         dataChan    // 调用上面函数需要的参数
}

func NewRunner(size int, dispatcher fn, executor fn) *Runner {
	return &Runner{
		make(chan string, 1),
		make(chan string, 1),
		dispatcher,
		executor,
		make(chan interface{}, size),
	}
}

// 开启生产任务
func (r *Runner) Start() {
	// 把任务状态设置为“生产”
	r.Controller <- READY_TO_DISPATH
	// 开始生产流程
	r.StartDispatcher()
}

// 开始生产
func (r *Runner) StartDispatcher() {
	// 由于生产和消费的流程是轮流执行,生产完成后,将任务状态设置为消费,消费完成后,将任务状态设置为生产,因此这里放在一个死循环中来实现
	for {
		select {
		// 从Controller管道中读取任务状态
		case c := <- r.Controller:
			if c == READY_TO_DISPATH {
				// 如果是生产状态,则调用生产方法
				if err := r.Dispatcher(r.ch); err != nil {
					// 如果生产过程发生错误,则返回错误结果
					r.Error <- CLOSE
				} else {
					// 如果生产完后,修改任务状态为“消费”
					r.Controller <- READY_TO_EXECUTE
				}
			}
			if c == READY_TO_EXECUTE {
				// 如果是消费状态,则调用消费方法
				if err := r.Executor(r.ch); err != nil {
					// 如果生产过程发生错误,则返回错误结果
					r.Error <- CLOSE
				} else {
					// 如果生产完后,修改任务状态为“生产”
					r.Controller <- READY_TO_DISPATH
				}
			}
		// 读取Error管道中的数据
		case e := <- r.Error:
			// 如果读取到的数据等于CLOSE常量的值,则执行退出操作
			if e == CLOSE {
				return
			}
		}
	}
}

5.6 开启定时任务

在taskrunner目录下新建start_task.go文件,该文件存储了启动定时任务的方法。

package taskrunner

import (
	"fmt"
	"time"
)

// 启动定时器
func Start() {
	// 创建任务对象
	runner := NewRunner(3, VideoClearDispatcher, VideoClearExecutor)
	work := NewWork(5, runner)
	go work.Start()
}

type Work struct {
	ticket *time.Ticker // 定时器
	runner *Runner      // 定时执行的任务
}

func NewWork(interval time.Duration, runner *Runner) *Work {
	return &Work {
		time.NewTicker(interval * time.Second), // 每个interval秒执行定时任务一次
		runner,
	}
}

// 执行定时任务
func (w *Work) Start() {
	fmt.Println("开启定时器...")
	for {
		select {
			case <- w.ticket.C:
				fmt.Println("执行任务...")
				// 如果定时器中可以读取到数据,则代表定时时间到了,开启定时器
				w.runner.Start()
		}
	}
}

在dbops目录中新建video_dao_test.go文件,定义一个测试方法,用于向video_del_rec表中插入一些数据。

package dbops

import (
	"fmt"
	"testing"
)

func TestAddVideoDelRec(t *testing.T) {
	for i := 0; i< 100; i++ {
		vid := fmt.Sprintf("vid-%d", i)
		AddVideoDelRec(vid)
	}
}

由于vid对应的文件在磁盘上不存在,所以测试前先把task.go文件中删除视频的代码注释掉。

/*TODO 在磁盘上删除视频
if err := DeleteVideo(vid.(string)); err != nil {
	// 将删除的异常原因记录到map中
	errMap.Store(vid, err)
	return
}*/

先运行测试文件,然后在运行schduler模块,执行结果如下:
在这里插入图片描述

发布了111 篇原创文章 · 获赞 41 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/zhongliwen1981/article/details/103476358
今日推荐