最近上头让我写个项目简单的官方网站,需求很简单,有前后端,前端负责获取要跳转的外链进行跳转和介绍视频的播放,后端负责传回外链和需要播放的视频。我拿到需求,想了想,这样子的需求就用不着数据库了,后端写个配置文件,传回固定的数据就可以了,视频嘛,就通过流的方式传给前端。
  确定好了实现方式,那就撸起袖子开干。经过简单思考,使用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内项目进程的相关信息。例图:

image.png

pm2 stop 0:停止进程id为0的进程。例图:

image.png

pm2 delete 0:彻底删除id为0的进程。例图:

image.png


爱吃鸡蛋饼
55 声望8 粉丝