最近上頭讓我寫個項目簡單的官方網站,需求很簡單,有前後端,前端負責獲取要跳轉的外鏈進行跳轉和介紹視頻的播放,後端負責傳回外鏈和需要播放的視頻。我拿到需求,想了想,這樣子的需求就用不着數據庫了,後端寫個配置文件,傳回固定的數據就可以了,視頻嘛,就通過流的方式傳給前端。
確定好了實現方式,那就擼起袖子開幹。經過簡單思考,使用vue3+koa2的方式來做。一切從簡,安裝vue3-cli和koa2來新建前後端項目。
一.vue3前端項目搭建
通過npm install -g @vue/cli或者yarn global add @vue/cli安裝好vue/cli,再通過vue create 項目名(自己用英文替代掉項目名)新建對應的項目。接下來,npm install 安裝一遍全部依賴,通過 npm 給自己的項目加個配套的element-plus,即通過npm install element-plus --save安裝好element-plus。再在main.js文件裏引用。
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
createApp(App).use(ElementPlus).mount('#app')
二.koa2後端項目搭建
具體的需求頁面就不描述,主要就是兩個get請求去請求後端。那麼後端怎麼做呢?一樣的,先通過 npm install koa-generator -g 安裝 koa2,再通過 koa2 項目名 創建好項目,最後 npm install 安裝一遍全部依賴。然後 npm start 跑一遍,能跑起來就是弄好了。
三.前後端聯調需要做的本地代理配置
1.前端方面:
若有文件vue.config.js,則在裏面寫上 proxy 代理規則,若沒有文件,則新建一個在項目頂層再寫上代理規則。規則大致如下:
module.exports = {
publicPath: './',
outputDir: './dist',
productionSourceMap: false,
lintOnSave: false,
devServer: {
port: 8808,//前端跑起來的端口
disableHostCheck: true,
hotOnly: false,
compress: true,
watchOptions: {
ignored: /node_modules/,
},
proxy: {//代理規則,代理到本地3000端口,使用“/api”重寫路徑到“/”
"/api": {
target: "http://127.0.0.1:3000/",
changeOrigin: true, // target是域名的話,需要這個參數
secure: false, // 設置支持https協議的代理
ws: false,
pathRewrite: {
"^/api": "/"
},
},
}
},
chainWebpack: config => {
config.plugins.delete('preload')
config.plugins.delete('prefetch')
},
css: {
sourceMap: false,
},
};
2.後端方面:
在項目的app.js文件內補充對應的代理規則,大致規則如下:
const Koa = require('koa')
const app = new Koa()
const views = require('koa-views')
const json = require('koa-json')
const onerror = require('koa-onerror')
const bodyparser = require('koa-bodyparser')
const logger = require('koa-logger')
const proxy = require('koa2-proxy-middleware')
const index = require('./routes/index')
const users = require('./routes/users')
const web = require('./routes/web')
const koaMedia = require('./routes/koaMedia')
// error handler
onerror(app)
// middlewares
app.use(bodyparser({
enableTypes:['json', 'form', 'text']
}))
app.use(json())
app.use(logger())
app.use(require('koa-static')(__dirname + '/public'))
app.use(views(__dirname + '/views', {
extension: 'ejs'
}))
app.use(koaMedia({
extMatch: /\.mp[3-4]$/i
}))
const options = {//後端項目與前端項目代理對接,target為前端端口,一樣通過“/api”重寫
targets: {
'/api': {
target: 'http://127.0.0.1:8808/',
ws: true,
changeOrigin: true,
pathRewrite: {
'^/api': '' //和前端代理一樣,選擇api替換
}
},
}
}
app.use(proxy(options));
// logger
app.use(async (ctx, next) => {
ctx.set("Access-Control-Allow-Origin", "*");//在app內通過該字段,使全部端口過來的信息都能通過。
const start = new Date()
await next()
const ms = new Date() - start
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
})
// routes
app.use(index.routes(), index.allowedMethods())
app.use(users.routes(), users.allowedMethods())
app.use(web.routes(), web.allowedMethods())
// error-handling
app.on('error', (err, ctx) => {
console.error('server error', err, ctx)
});
module.exports = app
按上述配置實現的話,前端發送請求時在路徑首部增加"/api"字段即可正確發送到後端,後端也可順利發送信息返回前端。
四.前後端實現視頻流並分段傳輸
1.前端方面:
<template>
<div class="videoPlay">
<video
ref="m3u8_video"
class="video-js vjs-default-skin vjs-big-play-centered"
controls
>
<source :src="videoSrc" type="video/mp4"/>
</video>
</div>
</template>
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import videojs from "video.js";
import zh from "video.js/dist/lang/zh-CN.json";
import 'videojs-flash'
const props = defineProps({
videoSrc: {//鏈接例子為:"/api/video?path=" + url;
type: String,
default: ""
}
})
const m3u8_video = ref();
let player;
const initPlay = async () => {
videojs.addLanguage("zh-CN", zh);
await nextTick();
const options = {
muted: true,
controls: true,
autoplay: false,
loop: false,
language: "zh-CN",
techOrder: ["html5"],
};
player = videojs(m3u8_video.value, options, () => {
if (props.autoPlay && props.videoSrc) {
player.play();
}
player.on("error", () => {
videojs.log("播放器解析出錯!");
});
});
};
const resetPlayer = () => {
player.load();
}
onMounted(() => {
initPlay();
});
//直接改變路徑測試
watch(
() => props.videoSrc,
() => {
player.pause();
player.src(props.videoSrc);
player.load();
if (props.videoSrc) {
player.play();
}
}
);
onBeforeUnmount(() => {
player?.dispose();
});
defineExpose({ resetPlayer })
</script>
<style lang="scss" scoped>
.videoPlay {
width: 100%;
height: 100%;
.video-js {
height: 100%;
width: 100%;
object-fit: fill;
::v-deep .vjs-big-play-button {
font-size: 2.5em !important;
line-height: 2.3em !important;
height: 2.5em !important;
width: 2.5em !important;
-webkit-border-radius: 2.5em !important;
-moz-border-radius: 2.5em !important;
border-radius: 2.5em !important;
background-color: #73859f;
background-color: rgba(115, 133, 159, 0.5) !important;
border-width: 0.15em !important;
margin-top: -1.25em !important;
margin-left: -1.75em !important;
}
.vjs-big-play-button .vjs-icon-placeholder {
font-size: 1.63em !important;
}
}
.vjs-paused{
::v-deep .vjs-big-play-button {
display: block !important;
}
}
}
:deep(.vjs-tech) {
object-fit: fill;
}
</style>
2.後端方面:
文件koaMedia.js
const fs = require('fs')
const path = require('path')
const mine = {
'mp4': 'video/mp4',
'webm': 'video/webm',
'ogg': 'application/ogg',
'ogv': 'video/ogg',
'mpg': 'video/mepg',
'flv': 'flv-application/octet-stream',
'mp3': 'audio/mpeg',
'wav': 'audio/x-wav'
}
let getContentType = (type) => {
if (mine[type]) {
return mine[type]
} else {
return null
}
}
let readFile = async(ctx, options) => {
// 確認客户端請求的文件的長度範圍
let match = ctx.request.header['range']
// 獲取文件的後綴名
let ext = path.extname(ctx.query.path).toLocaleLowerCase()
// 獲取文件在磁盤上的路徑
// let diskPath = decodeURI(path.resolve(options.root + ctx.query.path))
// 獲取文件的開始位置和結束位置
let bytes = match.split('=')[1]
let stats = fs.statSync(ctx.query.path)
// 在返回文件之前,知道獲取文件的範圍(獲取讀取文件的開始位置和開始位置)
let start = Number.parseInt(bytes.split('-')[0]) // 開始位置
let end = Number.parseInt(bytes.split('-')[1]) || start + 999999 // 結束位置
end = end > stats.size - 1 ? stats.size - 1 : end;
let chunksize = end - start + 1;
// 如果是文件類型
if (stats.isFile()) {
return new Promise((resolve, reject) => {
// 讀取所需要的文件
let stream = fs.createReadStream(ctx.query.path, {start: start, end: end})
// 監聽 ‘close’當讀取完成時,將stream銷燬
ctx.res.on('close', function () {
stream.destroy()
})
// 設置 Response Headers
ctx.set('Content-Range', `bytes ${start}-${end}/${stats.size}`)
ctx.set('Accept-Range', "bytes")
ctx.set("Content-Length", chunksize)
ctx.set("Connection", "keep-alive")
// 返回狀態碼
ctx.status = 206
// getContentType上場了,設置返回的Content-Type
ctx.type = getContentType(ext.replace('.',''))
stream.on('open', function(length) {
try {
stream.pipe(ctx.res)
} catch (e) {
stream.destroy()
}
})
stream.on('error', function(err) {
try {
ctx.body = err
} catch (e) {
stream.destroy()
}
reject()
})
// 傳輸完成
stream.on('end', function () {
resolve()
})
})
}
}
module.exports = function (opts = {}) {
// 設置默認值
let options = Object.assign({}, {
extMatch: ['.mp4', '.flv', '.webm', '.ogv', '.mpg', '.wav', '.ogg'],
root: process.cwd()
}, opts)
return async (ctx, next) => {
// 獲取文件的後綴名
if(ctx.url.indexOf("/video") > -1){//如果文件請求路徑有video,則下一步
let ext = path.extname(ctx.query.path).toLocaleLowerCase()
// 判斷用户傳入的extMath是否為數組類型,且訪問的文件是否在此數組之中
let isMatchArr = options.extMatch instanceof Array && options.extMatch.indexOf(ext) > -1
// 判斷用户傳輸的extMath是否為正則類型,且請求的文件路徑包含相應的關鍵字
let isMatchReg = options.extMatch instanceof RegExp && options.extMatch.test(ctx.query.path)
if (isMatchArr || isMatchReg) {
if (ctx.request.header && ctx.request.header['range']) {
// readFile 上場
return await readFile(ctx, options)
}
}}
await next()
}
}
由於node限制,所以上述文件的執行成功,需要先在main.js設置靜態文件路徑,即:app.use(require('koa-static')(__dirname + '/public')),才能順利讀取文件。
另外後端發送base64圖片也需要設置靜態文件路徑。其操作代碼類似於:
router.get('/getWebs', function (ctx, next) {
let fileArr = fs.readdirSync(path.join(__dirname,'../public/images/icon'),{encoding:'utf8', withFileTypes:true})
for(let i = 0; i < fileArr.length; i++){
let filePath = path.join(__dirname, `../public/images/icon/${fileArr[i].name}`);
let fileObj = fs.readFileSync(filePath);
urlData.data[i].background_image = `data:image/png;base64,${fileObj.toString('base64')}`;
}
ctx.body = {
success: true,
data: urlData.data
}
})
五.項目上線服務器
以winSCP軟件為例:
1.前端項目:
前端項目上線很簡單,這裏暫時不講複雜的webpack打包配置,畢竟只是簡單的項目上線,走個完整的流程。前端項目打包只需要執行npm run build打包獲得項目裏dist文件夾,把dist文件夾丟到對應的服務器上即可。
2.後端項目:
後端項目不需要打包,但是也不需要上傳node_module文件夾,把其餘文件夾上傳到服務器對應的後端文件夾中,winSCP打開對應的文件路徑,再運行項目。
但是,這裏要注意的是,不能像本地一樣直接運行 npm start命令,因為在服務器端這樣運行項目是無法獲取運行日誌的。如果運行 npm start命令,在服務器內是不能像開發時直接 ctrl + c來結束進程的。需要通過netstat -ano命令查看所有端口的佔用情況,在列表內查找端口對應的進程號來關閉進程。或者直接通過命令
netstat -nlp | grep 8080(舉例的端口號)
//-n --numeric的縮寫,即通過數值展示ip地址
//-l --listening的縮寫,只打印正在監聽中的網絡連接
//-p --program,打印相應端口號對應進程的進程號
來查找對應的進程號 PID ,再通過終止命令 kill -15 24971或者強制終止命令kill -9 24971來終止對應進程。
實際上,服務端運行後端node項目,是通過 pm2方式來管理進程的。在這之前,需要在項目文件頂層新建一個app.json文件,來容納原本的"npm start"命令。
//app.json
{
"apps": [
{
"name": "afa-info",
"script": "npm",
"args": ["start"],
"out_file": "./logs/afa-info-app.log",
"error_file": "./logs/afa-info-err.log"
}
]
}
接着就可以使用 pm2命令來啓動項目了,通過這種方式啓動,可以隨時打印出項目的日誌。通過pm2 start npm --name 項目名 – start來啓動項目,其中項目名需替換成項目的名稱,這個名稱和原項目內package.json文件內的 name 字段無關,僅做服務器進程識別作用。正常來説,這樣子把項目在服務器上跑起來,在瀏覽器輸入服務器的IP地址和端口訪問前端頁面,就可以看到原本前端項目的頁面,且前端請求正常獲取了後端返回的信息。至此,大功告成。
pm2常用命令:
⑴pm2 start npm --name 項目名 – start: 將後端項目在服務器上跑起來。
⑵pm2 status:查找pm2內項目進程的相關信息。例圖:
⑶ pm2 stop 0:停止進程id為0的進程。例圖:
⑷ pm2 delete 0:徹底刪除id為0的進程。例圖: