为什么需要这样一个工具?
前端的日常开发中,经常有一些图片需要放在cdn上,否则会导致项目包太大,严重降低项目的构建部署效率。尤其是小程序开发,超过2M的大小都没法启动项目。如果把账号分发出去,又不安全,那开发这样一个工具就非常有必要了!
解决痛点
- 方便快捷上传资源
- 避免分发七牛云账号造成不必要的麻烦
- 常用功能提取到工具降低使用成本
技术栈
前端:vue3 + element-plus
后端:egg + qiniu + Jwt + Docker + mongodb + mongoose
服务端功能实现
这里就直接开始将功能实现,不懂egg这个nodejs框架的可以提前学习学习,也可以参考这篇文章juejin.cn/post/707498…
七牛云 Nodejs Sdk
jwt简介
JWT(Json Web Token)是实现token技术的一种解决方案,JWT由三部分组成:
header(头)
、payload(载体)
、signature(签名)
- 头
HS384 HS512 RS256 RS384 RS512 ES256 ES384 ES512 PS256 PS384
有这几种 - 载体
iss:Issuer,发行者
sub:Subject,主题
aud:Audience,观众
exp:Expiration time,过期时间
nbf:Not before
iat:Issued at,发行时间
jti:JWT ID
- 签名
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
"your-256-bit-secret"
)
egg介绍
Egg.js 为企业级框架和应用而生,我们希望由 Egg.js 孕育出更多上层框架,帮助开发团队和开发人员降低开发和维护成本。
# 项目初始化
mkdir egg-example && cd egg-example
npm init egg --type=simple
npm i
目录结构
以下是初始化生成的项目目录结构,我们主要关注app/
┌┈ github github相关的配置
├┈ app 业务源码
├┈ controller 控制器
├┈ extend 扩展文件
├┈ middleware 中间件
├┈ model 数据模型
├┈ public 静态服务器目录
├┈ service 主要逻辑
├┈ router.js 路由文件
├┈ config 全局配置文件
├┈ config.default.js 默认的配置文件
├┈ logs 日志文件
├┈ run
├┈ test
├┈ typings
├┈ .autod.conf.js
├┈ .dockerignore
├┈ .eslintignore
├┈ .eslintrc.js
├┈ .gitignore
├┈ .travis.yml
├┈ package.json
└┈ README.md
controller
需要注意的是这里无需引入router、controller控制器文件,是因为框架事先的约定自动动将路由、控制器文件挂载到了app
上,极大的提高了编码效率,但前提是得按照框架的约束来
module.exports = (app) => {
const { router, controller } = app;
// 通过app引用中间件方法
const auth = app.middleware.auth();
// 路径前缀,类似 baseUrl
router.prefix("/image-upload-server");
// 路由 当路由匹配到时会先通过 auth 鉴权中间,成功后才会进入对应引入的控制器文件
router.get("/getInfo", auth, controller.user.index);
}
extend
框架提供了一种快速扩展的方式,只需在 app/extend
目录下提供扩展脚本即可
exports.md5 = str => {
return crypto.createHash('md5').update(str).digest('hex')
}
// 使用
this.ctx.helper.md5(data)
middleware
路由鉴权中间件,当用户触及的操作需要用户权限时就需要这么一个中间件来鉴权
// 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 {
// 通过jwt校验解析出用户参数
const data = await ctx.service.user.verifyToken(token);
// 利用jwt解析出来的参数去数据库查是否存在此人
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();
};
};
// 使用
const auth = app.middleware.auth();
router.get("/getInfo", auth, controller.user.index);
错误处理中间件,封装一个统一的错误处理中间件是非常必要的,否则每个接口都得单独写,增加大量的维护成本
// middleware/error_handler.js
module.exports = () => {
// 返回一个中间件处理函数
return async function errorHandler(ctx, next) {
try {
await next();
} catch (err) {
// 所有的异常都在 app 上触发一个 error 事件,框架会记录一条错误日志
ctx.app.emit("error", err, ctx);
const status = err.status || 500;
// 生产环境时 500 错误的详细错误内容不返回给客户端,因为可能包含敏感信息
const error =
status === 500 && ctx.app.config.env === "prod"
? "Internal Server Error"
: err.message;
// 从 error 对象上读出各个属性,设置到响应中
ctx.body = { error };
if (status === 422) {
ctx.body.detail = err.errors;
}
ctx.status = status;
}
};
};
// config/config.default.js
// add your middleware config here
config.middleware = ["errorHandler"];
config
全局配置文件
/**
* @param {Egg.EggAppInfo} appInfo app info
*/
const path = require('path')
module.exports = appInfo => {
/**
* built-in config
* @type {Egg.EggAppConfig}
**/
const config = (exports = {});
// use for cookie sign key, should change to your own and keep security
config.keys = appInfo.name + "_1646547622833_8013";
// add your middleware config here
config.middleware = ["errorHandler"];
// 配置数据库
config.mongoose = {
client: {
url: "mongodb://xxxxxxxxxxxxxxxxxxx",
options: {
useUnifiedTopology: true,
},
// mongoose global plugins, expected a function or an array of function and options
plugins: [],
},
};
// 防止网络攻击功能 关闭
config.security = {
csrf: {
headerName: 'x-csrf-token', // csrf 安全
enable: true,
},
};
// 自定义的全局参数
config.jwt = {
secret: "1cd3f51c-d342-4168-88fd-ee535d083c2a",
expiresIn: "30d",
};
// 配置 cors 跨域
config.cors = {
origin: '*'
}
// 静态资源服务器配置
config.static = {
prefix: '/public/',
dir: path.join(appInfo.baseDir, 'app/public')
}
// 上传 body multipart
config.multipart = {
// will append to whilelist
// egg 内置了 egg-multipart插件,有一套默认的文件扩展名白名单,当不在白名单的文件上传时会被拦截
// fileExtensions 这个就是追加操作,了解更多:https://github.com/eggjs/egg-multipart#upload-multiple-files
fileExtensions: [".docx", ".doc", ".xlsx", ".woff", ".ttf", ".eot"],
};
// add your user config here
const userConfig = {
// myAppName: 'egg',
};
return {
...config,
...userConfig,
};
};
以上就是egg框架的大致介绍,也可以参考官方文档:www.eggjs.org/zh-CN/intro… ,下面正式来实现具体的业务逻辑!
1.用户创建
// 创建用户 controller
async create() {
const { ctx, service } = this;
const body = ctx.request.body;
const userService = service.user;
// 参数校验
ctx.validate({
username: { type: "string" },
email: { type: "email" },
password: { type: "string" },
});
// 判断用户是否存在
if (await userService.findByUsername(body.username)) {
ctx.throw(422, "用户已存在");
}
if (await userService.findByEmail(body.email)) {
ctx.throw(422, "邮箱已存在");
}
// 保存用户
const user = await userService.creteUser(body);
// 生成token
const token = await userService.createToken({ userId: user._id });
// 发送响应
ctx.body = {
data: {
user: {
...ctx.helper._.pick(user, [
"email",
"username",
"channelDescription",
"avatar",
]),
token,
},
},
msg: "创建用户成功",
code: 0,
};
}
// user.js service
async creteUser(data) {
data.password = this.ctx.helper.md5(data.password);
const user = new this.User(data);
await user.save();
return user;
}
async createToken(data) {
return jwt.sign(data, this.app.config.jwt.secret, {
expiresIn: this.app.config.jwt.expiresIn,
});
}
2.登录实现
利用 jwt 实现登录,返回token存储在前端作为凭证
// user.js controller
const Controller = require('egg').Controller;
// 登录
class UserController extends Controller {
async login() {
const { ctx, service } = this;
const body = ctx.request.body;
const userService = service.user;
// 数据验证
ctx.validate({
email: { type: "email" },
password: { type: "string" },
});
// 校验邮箱是否存在
const user = await userService.findByEmail(body.email);
if (!user) {
ctx.throw(422, "用户邮箱不存在");
}
// 校验密码是否正确
if (this.ctx.helper.md5(body.password) !== user.password) {
ctx.throw(422, "用户密码错误");
}
// 生成token
const token = await userService.createToken({ userId: user._id });
// 发送数据
ctx.body = {
data: {
user: {
...ctx.helper._.pick(user, [
"email",
"username",
"channelDescription",
"avatar",
]),
token,
},
},
msg: "登录成功",
code: 0,
};
}
}
module.exports = UserController;
// user.js service
const Service = require('egg').Service
const jwt = require('jsonwebtoken')
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 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;
3.资源上传(重点)
实现的方案是:先将前端本地资源通过接口上传到服务器本地,然后再从服务器本地上传到七牛云,最后把服务器本地的资源删除
upload 接口controller
class UploadController extends Controller {
async uploadFiles() {
const { ctx } = this;
// el-upload 本地图片上传到服务器本地
const data = await ctx.service.upload.index();
console.log('=======================', data);
// 上传到服务器的资源往七牛云空间传输
// data.params 为夹杂在form-data内的 上传参数
// data.target 为本地上传到服务器资源的地址
const res = await ctx.service.upload.uploadQiniu(data.target, data.fileName, data.params);
// 等到服务器上的资源传输到了七牛云后,删除服务器上传资源,以免占用内存
await ctx.service.upload.deleteFile(data.target)
if (res) {
ctx.body = {
code: 0,
msg: "上传成功",
link: res
}
} else {
ctx.body = {
message: '上传失败',
};
}
}
}
upload 接口service
// form-data数据流写进服务器本地
async index() {
const { ctx } = this;
// 获取form-data数据流
const stream = await ctx.getFileStream();
const fileName = stream.filename;
// 定制资源上传到服务器的存储地址
const target = path.join(
this.config.baseDir,
`app/public/comfiles/${stream.filename}`
);
const result = await new Promise((resolve, reject) => {
// 创建文件 写流
const remoteFileStream = fs.createWriteStream(target);
// 将获取到的form-data数据流通过管道写进target地址内
stream.pipe(remoteFileStream);
let errFlag;
// 监控 写 发生错误
remoteFileStream.on("error", (err) => {
errFlag = true;
this.deleteFile(target);
remoteFileStream.destroy();
reject(err);
});
// 监控 写 结束
remoteFileStream.on("finish", async () => {
if (errFlag) return;
// stream.fields 夹在form-data内的参数
resolve({ fileName, target, params: stream.fields });
});
});
return result;
}
// 七牛云上传的相关配置初始化
mac() {
return new Promise((resolve) => {
const { accessKey, secretKey } = this.config.qiniu;
var mac = new qiniu.auth.digest.Mac(accessKey, secretKey);
resolve(mac);
});
}
// 七牛云上传的token生成
uptoken(bucket, key, params) {
const { isCoverUpload, prefix } = params;
return new Promise(async (resolve) => {
const putPolicy =
String(isCoverUpload) === "true"
? new qiniu.rs.PutPolicy({ scope: `${bucket}:${prefix}${key}` })
: new qiniu.rs.PutPolicy({ scope: `${bucket}` });
const mac = await this.mac();
const token = putPolicy.uploadToken(mac);
resolve(token);
});
}
// 服务器本地的资源上传到七牛云
async uploadQiniu(localFile, key, params) {
const { prefix } = params;
const { scope, baseUrl } = this.config.qiniu;
// 生成上传 Token
const token = await this.uptoken(scope, key, params);
const config = new qiniu.conf.Config();
const formUploader = new qiniu.form_up.FormUploader(config);
const extra = new qiniu.form_up.PutExtra();
// 创建可读流
const readerStream = fs.createReadStream(localFile);
return new Promise((resolve, reject) => {
const loadTime = Date.now();
formUploader.putStream(
token,
`${prefix}${key}`,
readerStream,
extra,
(err, ret) => {
if (!err) {
console.log("上传耗时:", `${Date.now() - loadTime}ms`);
console.log("操作人", this.ctx.user.username, this.ctx.user.email)
console.log(
"操作时间",
this.ctx.helper.formatTime("YY-mm-dd HH:MM:SS")
);
resolve(`${baseUrl}${ret.key}`);
} else {
console.log("七牛云上传失败", err);
reject("七牛云上传失败");
}
}
);
});
}
// 删除上传到服务器本地的资源
deleteFile(path) {
fs.unlink(path, (err) => {
if (err) {
console.log(err);
}
});
}
4.资源刷新
CDN的刷新这就比较简单了,就是调用七牛云的sdk实现就OK
// 文件刷新 service
async refresh() {
// 刷新参数
const urlsToRefresh = this.ctx.request.body.urls;
// 七牛云相关的配置初始化
const mac = await this.mac();
const cdnManager = new qiniu.cdn.CdnManager(mac);
//刷新链接,单次请求链接不可以超过100个,如果超过,请分批发送请求
return new Promise((resolve, reject) => {
cdnManager.refreshUrls(urlsToRefresh, function (err, respBody, respInfo) {
if (err) {
throw err;
}
console.log("statusCode==============", respInfo.statusCode);
if (respInfo.statusCode == 200) {
resolve(respInfo)
} else {
reject(respInfo)
}
});
})
}
// 目录刷新 service
async refreshDir() {
const dirsToRefresh = this.ctx.request.body.urls;
const mac = await this.mac();
const cdnManager = new qiniu.cdn.CdnManager(mac);
//刷新链接,单次请求链接不可以超过100个,如果超过,请分批发送请求
return new Promise((resolve, reject) => {
cdnManager.refreshDirs(dirsToRefresh, function (err, respBody, respInfo) {
if (err) {
throw err;
}
console.log("statusCode==============", respInfo);
if (respInfo.statusCode == 200) {
resolve(respInfo);
} else {
reject(respInfo);
}
});
});
}
3.七牛云资源预览
async getFileList() {
const query = this.ctx.request.body
const mac = await this.mac();
var config = new qiniu.conf.Config();
// 对应机房
config.zone = qiniu.zone.Zone_z0;
var bucketManager = new qiniu.rs.BucketManager(mac, config);
// bucket空间
var bucket = this.config.qiniu.scope;
// @param options 列举操作的可选参数
// prefix 列举的文件前缀
// marker 上一次列举返回的位置标记,作为本次列举的起点信息
// limit 每次返回的最大列举文件数量
// delimiter 指定目录分隔符
return new Promise((resolve, reject) => {
bucketManager.listPrefix(
bucket,
query,
function (err, respBody, respInfo) {
if (err) {
console.log(err);
throw err;
}
if (respInfo.statusCode == 200) {
//如果这个nextMarker不为空,那么还有未列举完毕的文件列表,下次调用listPrefix的时候,
//指定options里面的marker为这个值
var nextMarker = respBody.marker;
var commonPrefixes = respBody.commonPrefixes;
console.log(nextMarker);
console.log(commonPrefixes);
resolve(respBody)
} else {
console.log(respInfo.statusCode);
console.log(respBody);
reject(respBody)
}
}
);
})
}
前端页面功能实现
1. 资源上传、CDN刷新
资源上传可以指定上传路径,如果不指定,默认是上传到对应bucket的根目录,当我们不想改变资源在七牛云的路径但资源内容又改变了的时候,就需要用到覆盖上传,这样可以达到路径不边资源被替换成了新资源。使用覆盖上传需要注意原来资源会继续缓存一段时间,导致没法立即看到新上传的资源,怎么解决呢?那就需要用到CDN刷新!CDN刷新分为文件刷新和目录刷新,使用时把需要刷新的链接填入进行刷新即可。
2.资源预览
资源预览功能就是提供给用户去搜索对应路径下资源并展示出来,用户也可以自行输入想要查询的路径。展示资源的每一项都有两个按钮,可以单独的去刷新某个资源,复制某个资源的链接,当遇到图片时可以点击预览大图
服务部署
服务部署走了不少弯路,想着是和前端静态包一样,直接上云效部署,后面一直没部署成功,都不知道是构建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:7001 -it koa-demo /bin/bash
# 或者
$ docker container run -p 8000:7001 -it koa-demo:0.0.1 /bin/bash
-p
参数:容器的 7001 端口映射到本机的 8000 端口。-it
参数:容器的 Shell 映射到当前的 Shell,然后你在本机窗口输入的命令,就会传入容器。koa-demo:0.0.1
:image 文件的名字(如果有标签,还需要提供标签,默认是 latest 标签)。/bin/bash
:容器启动以后,内部第一个执行的命令。这里是启动 Bash,保证用户可以使用 Shell。
上面操作一切正常后显示这样
root@66d80f4aaf1e:/app#
3. 启动服务
可以 Dockerfile CMD命令,也可以手动输入命令启动,当然如果你下载了docker客户端,也可以在客户上启动
点击镜像后的启动按钮启动 已经启动的在运行的镜像 推荐阮一峰大佬的docker入门文章
最后浏览器访问 localhost:8000
在本地运行成功后才可证明构建镜像是OK的
4. 上云效流水线
我就不详细的分享云效流水线相关的配置,juejin.cn/post/703441…
注意:
遇到的坑
当项目开发完后测试阶段,上传内存较大的视屏文件,一直报 413 size too large
,上网查一直说是 egg 配置问题,后面果断禁用这个 bodyParser
,还是无济于事,后面经排查才知道是nginx有限制,加上这条就OK了client_max_body_size 1024M;
location ^~/api {
client_max_body_size 1024M;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://gateway.caibeitv.com/;
}
最后
试着通过nodejs和前端技术结合去解决一些问题,你会得意想不到的收货。也能在团队中刷刷存在感。