流媒體網站開發(二)

一、視頻服務搭建

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模塊,執行結果如下:
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章