Egg从0到1开发部署一个视频网站服务

580 阅读14分钟

「本文正在参与技术专题征文Node.js进阶之路,点击查看详情

序言

行走在芒芒人海,奔命于高耸楼宇之间,为了不被别人卷死,为了在人群之间看着不那么像一条咸鱼,即使工作再忙,也不能停止学习,在内卷的深沟里越走越远。作为一名合格的前端工程师,光会html、css、js那是不行的,这不得涉足涉足服务端吗?于是就有了接下来的事故

技术栈

  1. EggJs
  2. MongoDB
  3. Mongoose
  4. JWT
  5. Docker
  6. K8s
  7. Md5
  8. 阿里云视频点播
  9. 云效流水线

技术选型

Express

  • 一个基于 Node.js 平台的极简、灵活的 web 应用开发框架。
  • 沿用 Node.js的Error-First的模式(第一个参数是error对象)。
  • 集成了路由、静态文件功能模块 Koa
  • Express 幕后的原班人马打造,致力于成为一个更小、更富有表现力、更健壮的 Web 框架。
  • 利用 async 函数,丢弃回调函数.有力地增强错误处理。
  • 不在内核方法中绑定任何中间件,它仅仅提供了一个轻量优雅的函数库,使得编写 Web 应用变得得心应手

Express和Koa的区别

区别ExpressKoa
异步方式基于回调, 不会等待异步完成使用async/await解决回调问题,异步完成之后才会执行下一步
解决错误Error-First的模式解决错误使用 try/catch的方式解决错误
集成度集成度高,自带部分中间件没有捆绑任何中间件
中间件模型线性模型洋葱模型
响应机制立刻响应(res.json/res.send),上层不能再定义其他处理中间件执行完之后才响应(ctx.body = **),每一层都可以对响应进行自己的处理

线性模型和洋葱模型的区别,前者只有1~5,后者1~9 image.png

Egg这个框架是阿里基于Koa来实现的一个企业级的开源项目,对安全、错误处理、扩展性等做了很好的处理

Egg官网

image.png

项目介绍

egg项目目录结构

|--.github               github Action ci 相关配置
|--app                   应用源码
    |--controller        控制器文件
    |--extend            扩展文件
    |--middleware        中间件文件
    |--model             数据模型
    |--public            静态服务器文件
    |--service           控制器逻辑文件
    |--router.js         路由文件
|--config                项目配置
    |--config.default.js 默认配置文件
    |--config.local.js   本地配置文件
    |--config.prod.js    生成配置文件
    |--plugin.js         插件配置文件
|--logs                  项目自动生成的日志文件
|--run                   项目自动生成的文件
|--test                  项目自动生成的文件
|--typings               项目自动生成的类型相关文件
|--.dockerignore         docker忽略文件
|--.eslintignore         eslint忽略文件
|--.eslintrc.js          eslint
|--.gitignroe            git
|--Dockerfile            Docker文件
|--jsconfig.json
|--package.json
|--README.md

本次项目是要实现一个简易的视频网站,功能包括:

  • 用户注册
  • 用户登录
  • 用户更新
  • 用户删除
  • 频道订阅
  • 取消订阅
  • 创建视频
  • 视频详情
  • 视频列表
  • 视频发布
  • 阿里云视频点播对接
  • 视频删除
  • 视频更新
  • 视频评论
  • 删除评论
  • 视频点赞
  • 取消点赞

项目初始化

npm i create-egg

create-egg project-name

cd project-name

npm install

npm run dev

中途有一个选择样板的操作,我们这里选择第一个为例来演示 image.png

默认端口7001,此刻一个简易的egg应用就完成了 image.png

Eslint初始化

image.png

初始化 mongoose 配置

  1. 依赖下载 npm i egg-mongoose -S

image.png 2. config/plugin.js导出依赖

## config/plugin.js

exports.mongoose = {
  enable: true,
  package: "egg-mongoose",
};
  1. config.default.js初始化插件
module.exports = appInfo => {
  const config = exports = {};
  config.keys = appInfo.name + '_1647158144853_8798';
  // add your middleware config here
  config.middleware = [];

  config.mongoose = {
    client: {
      url: "连接数据URL"
      options: {
        useUnifiedTopology: true,
      },
      // mongoose global plugins, expected a function or an array of function and options
      plugins: [],
    },
  };
  
  return {
    ...config,
    ...userConfig,
  };
};

tips: mongodb数据库费用还是挺高的,推荐一个免费的给大家来测试开发使用,有512M的内存足够用了 https://cloud.mongodb.com

配置表单校验工具

npm i egg-validate -S

## config/plugin.js
exports.validate = {
  enable: true,
  package: "egg-validate",
};

会直接挂载到上下文,而且默认校验request.body,也可以传数据源进去,错误提示都是项目内置好了,无需自行配置

// 参数校验
ctx.validate({
  username: { type: "string" },
  email: { type: "email" },
  password: { type: "string" },
});

ctx.validate(
  {
    title: { type: "string", required: false },
    description: { type: "string", required: false },
    vodVideoId: { type: "string", required: false },
    cover: { type: "string", required: false },
  },
  body
);

了解更多 github.com/eggjs/egg-v…

配置跨域处理插件

npm i egg-cors -S

## config/plugin.js
exports.validate = {
  enable: true,
  package: "egg-cors
};
## config/config.default.js
config.cors = {
    origin: '*'
}
//config.cors = {
  // {string|Function} origin: '*',
  // {string|Array} allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH'
//};
## 白名单配置
config.security = {
  domainWhiteList: [ 'http://localhost:4200' ],
};

更多有关安全相关的配置 https://www.eggjs.org/zh-CN/core/security https://github.com/eggjs/egg-cors

实现第一个接口

  1. 定义MongoDB schema 数据模型
module.exports = app => {
  // 由于mongoose在前面的步骤配置好了且挂载到了app,所以可以直接使用  
  const mongoose = app.mongoose;
  const Schema = mongoose.Schema;

  const userSchema = new Schema({
    username: {
      // 用户名
      type: String,
      required: true,
    },
    email: {
      // 邮箱
      type: String,
      required: true,
    },
    password: {
      // 密码
      type: String,
      select: false, // 查询中不包含该字段
      required: true,
    },
    avatar: {
      // 头像
      type: String,
      default: null,
    },
    cover: {
      type: String, // 封面
      default: null,
    },
    channelDescription: {
      // 频道介绍
      type: String,
      default: null,
    },
    subscribersCount: {
      type: Number,
      default: 0,
    },
    createdAt: {
      // 创建时间
      type: Date,
      default: Date.now,
    },
    updatedAt: {
      // 更新时间
      type: Date,
      default: Date.now,
    },
  });

  return mongoose.model('User', userSchema);
};

  1. 创建路由
module.exports = app => {
  const { router, controller } = app;
  // 用户注册
  router.post('/user', controller.user.createUser)
};
  1. 创建控制器
const Controller = require("egg").Controller;

class UserController extends Controller {
  async createUser() {
    const { ctx, service } = this;
    const body = ctx.request.body;
    const userService = service.user;
    // 1、数据校验,数据有问题错误自动会抛出来
    ctx.validate({
      username: { type: "string" },
      email: { type: "email" },
      password: { type: "string" },
    });
    // 2、校验用户名是否存在
    if (await userService.findByUsername(body.username)) {
      ctx.throw(422, "用户已存在");
    }
    // 3、校验用户邮箱是否存在
    if (await userService.findByEmail(body.email)) {
      ctx.throw(422, "邮箱已存在");
    }
    // 4、创建用户、数据入库
    const user = await userService.creteUser(body);
    // 5、响应结果
    ctx.body = {
      user: {
        ...ctx.helper._.pick(user, [
          "email",
          "username",
          "channelDescription",
          "avatar",
        ]),
        token,
      },
    };
    ctx.body = "hi, egg";
  }
}

module.exports = UserController;
  1. 创建service
const Service = require('egg').Service
class UserService extends Service {
  get User() {
    return this.app.model.User;
  }
  // 根据用户名查找用户
  findByUsername(username) {
    return this.User.findOne({
      username,
    });
  }
  // 根据邮箱查找用户
  findByEmail(email) {
    return this.User.findOne({
      email,
    }).select("+password");
  }
  // 创建用户
  async creteUser(data) {
    // 给密码加密,这里有一个 helper 工具的封装放到后面来介绍
    data.password = this.ctx.helper.md5(data.password);
    // 数据写入数据库并返回生成的用户数据
    const user = new this.User(data);
    await user.save();
    return user;
  }
}

说明:根据官方推荐的写法,controller控制器文件内容做一些表单的校验,具体业务逻辑、操作数据库推荐放到service文件中

  1. 接口完成、测试

可以到数据库验证是否创建了一个新用户,有关数据库的使用就不做详细的介绍,可以先掌握基本的数据操作再来继续往下进行!

  1. 完成一个接口后是不是积累了些疑问?
  1. 为啥所有的文件都不需要引入就可以直接使用?
  • 是因为框架做了封装,凡是controller、config、middleware、service、model等模块下的文件都会自动挂载到对应的模块上最后统一挂载到全局的app对象上,所以可以通过 this来调用
  1. 怎么理解this? console.log(Object.keys(this)) => ['ctx', 'app', 'config', 'service']
  • ctx Context 是一个请求级别的对象
  • app Application 是全局应用对象,在一个应用中,只会实例化一个,它继承自 Koa.Application,在它上面我们可以挂载一些全局的方法和对象。
  • config 应用的配置
  • service 应用所有的 service

框架的自定义扩展

框架虽然对整个项目做了很多约束,进而来规范项目结构,也有利于提高效率,但是也预留很强的自主扩展性,用户可以根据自己的需求来自行扩展。这里只是起一个抛转引玉作用,更多的扩展还是到官网去浏览www.eggjs.org/zh-CN/basic…

1.给应用增加生命周期监控的功能 框架提供了这些 生命周期函数供开发人员处理:

  • 配置文件即将加载,这是最后动态修改配置的时机(configWillLoad
  • 配置文件加载完成(configDidLoad
  • 文件加载完成(didLoad
  • 插件启动完毕(willReady
  • worker 准备就绪(didReady
  • 应用启动完成(serverDidReady
  • 应用即将关闭(beforeClose

怎么创建?

## /app.js
class AppBootHook {
  constructor(app) {
    this.app = app;
  }
  configWillLoad() {
    // 此时 config 文件已经被读取并合并,但是还并未生效
    // 这是应用层修改配置的最后时机
    // 注意:此函数只支持同步调用

    // 例如:参数中的密码是加密的,在此处进行解密
    // this.app.config.mysql.password = decrypt(this.app.config.mysql.password);
    // 例如:插入一个中间件到框架的 coreMiddleware 之间
    // const statusIdx = this.app.config.coreMiddleware.indexOf("status");
    // this.app.config.coreMiddleware.splice(statusIdx + 1, 0, "limit");
  }

  async didLoad() {
    // 所有的配置已经加载完毕
    // 可以用来加载应用自定义的文件,启动自定义的服务

    // 例如:创建自定义应用的示例
    // this.app.queue = new Queue(this.app.config.queue);
    // await this.app.queue.init();

    // 例如:加载自定义的目录
    // this.app.loader.loadToContext(path.join(__dirname, "app/tasks"), "tasks", {
      // fieldClass: "tasksClasses",
    // });
  }

  async willReady() {
    // 所有的插件都已启动完毕,但是应用整体还未 ready
    // 可以做一些数据初始化等操作,这些操作成功才会启动应用

    // 例如:从数据库加载数据到内存缓存
    // this.app.cacheData = await this.app.model.query(QUERY_CACHE_SQL);
  }

  async didReady() {
    // 应用已经启动完毕

    // const ctx = await this.app.createAnonymousContext();
    // await ctx.service.Biz.request();
  }

  async serverDidReady() {
    // http / https server 已启动,开始接受外部请求
    // 此时可以从 app.server 拿到 server 的实例

    // this.app.server.on("timeout", (socket) => {
      // handle socket timeout
    // });
    
    //应用启动后,可以监听每个进来的请求
    this.app.on("request", (ctx) => {
      // log receive request
      // 比如,可以打印请求日志
    });
    this.app.once('server', (server) => {
        // websocket
    });
    this.app.on('error', (err, ctx) => {
        // report error
    });
    this.app.on('response', (ctx) => {
        // ctx.starttime is set by framework
        const used = Date.now() - ctx.starttime;
        // log total cost
    });
  }
}

module.exports = AppBootHook;

  1. 扩展Helper
## /app/extend/helper.js

// 将lodash挂载到app上
const _ = require('lodash')
exports._ = _
// 使用
this.ctx.helper._.pick(user, [
  "email",
  "username",
  "channelDescription",
  "avatar",
])
  1. 扩展application 举个例子,扩展一个阿里云点播初始化的公共方法
## /app/extend/application.js
// 阿里云点播依赖
const RPCClient = require("@alicloud/pop-core").RPCClient;

// 初始化方法
function initVodClient(accessKeyId, accessKeySecret) {
  const regionId = "cn-shenzhen"; // 点播服务接入地域
  const client = new RPCClient({
    //填入AccessKey信息
    accessKeyId: accessKeyId,
    accessKeySecret: accessKeySecret,
    endpoint: "http://vod." + regionId + ".aliyuncs.com",
    apiVersion: "2017-03-21",
  });
  return client;
}
// 定义一个空对象,作为缓存
let vodClient = null

// 导出方法
module.exports = {
  get vodClient() {
    if (!vodClient) {
      const { accessKeyId, accessKeySecret } = this.config.vod;
      vodClient = initVodClient(accessKeyId, accessKeySecret);
    }
    return vodClient;
  }
};
// 调用
this.ctx.body = await this.app.vodClient.request("RefreshUploadVideo", params, {});
  1. 封装一个用户鉴权中间件 在日常开发中,通常是很多个接口需要做用户鉴权处理,如果每个接口都去写一遍未免也太麻烦了,因此我们封装一个中间件来处理

中间件扩展按照框架的约定,放在 /app/middleware/ 文件夹下

## /app/middleware/auth.js

module.exports = (options = { required: true }) => {
  // 返回一个中间件处理函数
  return async (ctx, next) => {
    // 获取请求头 token
    let token = ctx.headers["authorization"]; // Bearer + ' ' + token
    token = token ? token.split("Bearer ")[1] : null;

    if (token) {
      // next
      try {
        const data = await ctx.service.user.verifyToken(token);
        const user = await ctx.model.User.findById(data.userId);
        ctx.user = user;
      } catch (error) {
        ctx.throw(401);
      }
    } else if (options.required) {
      ctx.throw(401);
    }

    await next();
  };
};
## /app/router.js

// 需要鉴权的接口会先进入 鉴权中间件,通过 => 返回当前用户信息进入接口控制器内 不通过 => 鉴权失败 401
router.patch("/user", auth, controller.user.update);

JWT

image.png

JSON Web 令牌使用的一些场景:

  • 授权:这是使用 JWT 最常见的场景。用户登录后,每个后续请求都将包含 JWT,从而允许用户访问该令牌允许的路由、服务和资源。单点登录是当今广泛使用 JWT 的一项功能,因为它的开销很小并且能够在不同的域中轻松使用。
  • 信息交换:JSON Web 令牌是在各方之间安全传输信息的好方法。因为可以对 JWT 进行签名(例如,使用公钥/私钥对),所以您可以确定发件人就是他们所说的那个人。此外,由于使用标头和有效负载计算签名,您还可以验证内容没有被篡改。

了解更多

JWT官网

具体实现

## /config/config.default.js

config.jwt = {
    // 加密秘钥
    secret: "1cd3f51c-d342-4168-88fd-ee535d083c2a",
    // 过期时间
    expiresIn: "30d",
};
## /app/service/user.js

const jwt = require('jsonwebtoken')
class UserService extends Service {
  get User() {
    return this.app.model.User;
  }
  // 创建token,登录成功回调给用户  
  async createToken(data) {
    return jwt.sign(data, this.app.config.jwt.secret, {
      expiresIn: this.app.config.jwt.expiresIn,
    });
  }
    
  async verifyToken(token) {
    return jwt.verify(token, this.app.config.jwt.secret);
  }
}

module.exports = UserService;
// 生成token
const token = await userService.createToken({ userId: user._id });
// 解析token返回payload数据
ctx.service.user.verifyToken(token);

阿里云视频点播接入

视频点播(ApsaraVideo VoD,简称VoD)是集视频采集、编辑、上传、媒体资源管理、自动化转码处理(窄带高清TM)、视频审核分析、分发加速于一体的一站式音视频点播解决方案。

image.png

怎样系统的了解 点播 ?

  1. 阿里云帮助中心(搜索视频点播) help.aliyun.com/
  2. 官方推荐的学习路径

image.png

作为前端 + Node 的技术栈,我们作重看两部分

web上传SDK

web端示例下载

Node上传SDK

具体代码实现

var uploader = new AliyunUpload.Vod({ 
    //userID,必填,您可以使用阿里云账号访问账号中心(https://account.console.aliyun.com/),即可查看账号ID 
    userId:"122", 
    //上传到视频点播的地域,默认值为'cn-shanghai', 
    //eu-central-1,ap-southeast-1 
    region:"", 
    //分片大小默认1 MB,不能小于100 KB(100*1024) 
    partSize: 1048576, 
    //并行上传分片个数,默认5 
    parallel: 5, 
    //网络原因失败时,重新上传次数,默认为3 
    retryCount: 3, 
    //网络原因失败时,重新上传间隔时间,默认为2秒 
    retryDuration: 2, 
    //开始上传 
    'onUploadstarted': function (uploadInfo) { 
        // 需要拿到上传地址,上传凭证
        Node实现的接口
        // 视频上传
        uploader.setUploadAuthAndAddress(uploadInfo, data.UploadAuth, data.UploadAddress, data.VideoId);
    }, 
    //文件上传成功 
    'onUploadSucceed': function (uploadInfo) { }, 
    //文件上传失败 
    'onUploadFailed': function (uploadInfo, code, message) { }, 
    //文件上传进度,单位:字节 
    'onUploadProgress': function (uploadInfo, totalSize, loadedPercent) { }, 
    //上传凭证或STS token超时 
    'onUploadTokenExpired': function (uploadInfo) { }, 
    //全部文件上传结束 
    'onUploadEnd':function(uploadInfo){ } 
});

作重讲一下上传的逻辑

'onUploadstarted': function (uploadInfo) { 
    // 上传方式,需要根据uploadInfo.videoId是否有值(该值由SDK通过localstorage获取),调用视频点播的不同接口获取uploadauth和uploadAddress 
    // 如果videoId有值,调用刷新视频上传凭证接口,否则调用创建视频上传凭证接口 
    // 上传过程中,如果在点播控制台删除了视频,即videoId不存在了,则会产生冲突,会出现控制台不存在videoId,而浏览器存在videoId的情况,报InvalidVideo.NotFound错误。此时,需手动清除localstorage 
    var url = ''; 
    if (uploadInfo.videoId) { 
        //如果uploadInfo.videoId存在,调用刷新视频上传凭证接口 
        url = 'refreshUrl'; 
        // 此处 URL 需要替换为服务端AppServer 对应的地址 
    } else { 
        //如果uploadInfo.videoId不存在,调用获取视频上传地址和凭证接口 
        url = 'createUrl'; 
        // 此处 URL 需要替换为服务端AppServer 对应的地址 
    } 
    // 以下请求实现为示例,用于演示设置凭证 
    // 获取 UploadAuth, UploadAddress, VideoId 可能因 AppServer 实现有差异 
    fetch(url).then((res) => res.json()).then((data) => {
        uploader.setUploadAuthAndAddress(uploadInfo, data.UploadAuth, data.UploadAddress, data.VideoId); 
    }); 
},
//上传凭证超时 
'onUploadTokenExpired': function (uploadInfo) { 
    //实现时,根据uploadInfo.videoId调用刷新视频上传凭证接口重新获取UploadAuth 
    //从点播服务刷新的uploadAuth,设置到SDK里 
    var url = 'refreshUrl'; 
    // 此处URL需要替换为服务端AppServer对应的地址 
    // 以下请求实现为示例,用于演示设置凭证 
    // 获取 UploadAuth, UploadAddress, VideoId 可能因 AppServer 实现有差异 
    fetch(url).then((res) => res.json()).then((data) => { 
        uploader.resumeUploadWithAuth(data.UploadAuth); 
    }); 
},

视频上传成功后,将视频播放地址、播放凭证一起写入MongoDB数据库中

image.png

服务部署

这里走了一圈弯路,想着是直接上云效部署,后面一直没部署成功,都不知道是构建docker镜像除了问题还是k8s集群编排除了问题。本着复杂的问题简单化原则,将构建和部署分开,一步一步推进

  1. 本地安装docker客户端
  2. 构建镜像
  • 编写Dockerfile
FROM node

RUN mkdir -p /home/egg

WORKDIR /home/egg

COPY package.json /home/egg/package.json

RUN npm install --registry=https://registry.npm.taobao.org

COPY . /home/egg

EXPOSE 7001

CMD npm run start
  • 构建镜像
$ docker image build -t koa-demo .
# 或者
$ docker image build -t koa-demo:0.0.1 .
  • 生成容器
$ docker container run -p 8000:3000 -it koa-demo /bin/bash
# 或者
$ docker container run -p 8000:3000 -it koa-demo:0.0.1 /bin/bash
  • -p参数:容器的 3000 端口映射到本机的 8000 端口。
  • -it参数:容器的 Shell 映射到当前的 Shell,然后你在本机窗口输入的命令,就会传入容器。
  • koa-demo:0.0.1:image 文件的名字(如果有标签,还需要提供标签,默认是 latest 标签)。
  • /bin/bash:容器启动以后,内部第一个执行的命令。这里是启动 Bash,保证用户可以使用 Shell。

上面操作一切正常后显示这样

root@66d80f4aaf1e:/app# 
  1. 启动服务 可以 Dockerfile CMD命令,也可以手动输入命令启动,当然如果你下载了docker客户端,也可以在客户上启动

点击镜像后的启动按钮启动 image.png 已经启动的在运行的镜像 image.png 推荐阮一峰大佬的docker入门文章

最后浏览器访问 localhost:8000

在本地运行成功后才可证明构建镜像是OK的

  1. 上云效流水线 我就不详细的分享云效流水线相关的配置,juejin.cn/post/703441…

截屏2022-03-14 下午11.16.17.png

注意:

截屏2022-03-14 下午11.22.30.png

完结

文章里主要是列了运用的到的技能点,以及通过简单的代码实现来理解,可能光看这篇文章应该没啥收获,感兴趣的可以拉下源码,按照我这篇文章的脉络来研究,相信你会有很大的收获!

源码