vue+Nodejs+Koa搭建前后端系统(九)-- 上传图片
web2.0的到来使网页世界正式进入了寒武纪,各式各样的多媒体资源屡见不鲜,上传资源变得刻不容缓!
前言
本文是在该系列的基础上,针对前后端代码的修改。
准备
HTTP上传图片时Content-Type值常见的有2种:application/json和multipart/form-data
前端准备
修改axios配置
/** /src/https.ts */ const http: MyAxiosInstance = axios.create({ baseURL: process.env.NODE_ENV === 'production' ? httpHost : '/nodeApi/', /**New Code Start*/ headers: { 'Content-Type': 'application/json',//将axiox的Content-Type值默认为application/json }, /**New Code End*/ timeout: 60000, }); /**其他代码省略**/ /**请求拦截器 */ http.interceptors.request.use(function (config) { const token = window.localStorage.getItem('token'); const username = userStore.userName; if (token) { config.headers.authorization = token; } /**New Code Start*/ //若请求Content-Type值为application/json,则处理请求数据 if (config.headers['Content-Type'] === 'application/json' && username && config.data) { /**New Code End*/ try { config.data = { username: username, ...config.data } } catch (e) { console.error(`请求拦截error:${e}`) } } return config; }, function (error) { return Promise.reject(error); });
上述代码修改了请求拦截,即:若请求类型为application/json时,则处理;否则(主要是针对multipart/form-data),不处理。
后端准备
1.安装koa-body
npm install koa-body
2.安装hexoid
npm install --save hexoid
3.安装dayjs
npm install dayjs
4.新建路由文件 /routes/files.js (用于接收上传图片请求并处理)
5.新建 /public/uploads/ 目录,用于存放上传的图片
数据库准备(非必须)
新建一张用于存储上传图片信息的表
CREATE TABLE user_uploads( src VARCHAR(510) NOT NULL PRIMARY KEY COMMENT '文件地址', create_time TIMESTAMP NOT NULL COMMENT '上传时间', title VARCHAR(255) COMMENT '文件标题', description VARCHAR(1000) COMMENT '文件描述', username VARCHAR(20) COMMENT '上传人', group_by VARCHAR(20) COMMENT '分组', mimetype VARCHAR(255) COMMENT 'MIME类型' ) COMMENT '文件上传维护表';
对之前代码修改
为了更加工整一些,作出如下修改:
1.修改create_user 表
#删除create_user 表的id列 ALTER TABLE create_user DROP COLUMN id; #设置create_user 表的username列为主键 ALTER TABLE create_user ADD PRIMARY KEY (username);
修改后的表结构
2.后端代码新增配置页
为了统一后端的一些配置项,在后端项目根目录新建index.config.js文件
代码为:
/** /index.config.js */ module.exports = { //不验证用户名的接口 no_verify: ["/login/loginIn", "/token/refresh", "/users/register", "/files/upload"], //静态文件夹路径 static_basepath:'./public', //上传文件存储的目录 static_uploadpath:'uploads' }
然后修改引用的地方
/** /app.js */ /** 其他代码省略 */ const { verifyToken } = require("./middleware/jwt_copy1.js"); const { no_verify, static_basepath } = require("./index.config.js"); app.use(require("koa-static")(path.join(static_basepath)));//__dirname + "/public" app.use(verifyToken({ no_verify: no_verify }));
前端处理上传图片的几种方式
使用 FormData 对象
可以使用原生form标签,实现FormData 对象上传图片
上传
但该方法有默认行为,比如上传成功后会跳转页面,而且不够灵活。鉴于此,可用js配合,使其更加灵活:
上传 import { reactive, ref } from "vue"; import http from "@/http"; import { ElMessage } from "element-plus"; //要上传图片的File对象数组,用于提交后台 const files = ref([]); //要上传图片的DataUrl数组,用于预览图片(这里也可以用BlobUrl) const previewImgs = ref([]); const getFiles = (e: any) => { files.value = files.value.concat([...e.target.files]); files.value.forEach((file: File, index: number) => { const reader = new FileReader(); reader.readAsDataURL(file); reader.addEventListener("load", () => { previewImgs.value[index] = reader.result; }); }); }; const upload = () => { //创建formData对象 const formData = new FormData(); //给formData对象添加files[]成员,多图片 for (let i in files.value) { formData.append("files[]", files.value[i]); } formData.append("username", 'xyz'); http.post("files/upload", formData, { headers: { "Content-Type": "multipart/form-data", }, }).then((data: any) => { ElMessage({ message: "上传成功", type: "success", }); }).catch((err: any) => { ElMessage({ message: err.message, type: "error", }); }); };
使用 Base64 编码
这种方法将文件转换成 Base64 编码的字符串,然后通过普通的 JSON 格式发送给服务器。这种方式适用于较小的文件,因为 Base64 编码会增加数据大小。
上传 import { reactive, ref } from "vue"; import http from "@/http"; import { ElMessage } from "element-plus"; //要上传图片的File对象数组,用于提交后台 const files = ref([]); const getFiles = (e: any) => { files.value = files.value.concat([...e.target.files]); }; /** *** 将文件转换为base64 *** files 文件数组,File[] *** callback 解析完成的回调方法,该回调方法第一个参数是解析的base64数组,第二个参数是对应的文件名称数组 */ const readFiles = (files: File[] = [], callback: (f: string[],n:string[]) => void) => { const len = files.length; let readNum = 0; const results = []; for (let i = 0; i { readNum++; results.push(e.target.result); if (readNum === len) { callback(results,files.map((item: any) => item.name)); } }; reader.onerror = () => { readNum++; if (readNum === len) { callback(results,files.map((item: any) => item.name)); } }; reader.readAsDataURL(files[i]); } }; const upload = () => { readFiles(files.value, (base64List: string[], filenameList: string[]) => { http.post("files/upload", { files: base64List, filename: filenameList, }).then((data: any) => { ElMessage({ message: "上传成功", type: "success", }); }).catch((err: any) => { ElMessage({ message: err.message, type: "error", }); }); }); };
后端处理上传图片
1.编写路由文件
/** /routes/files.js */ const path = require("path"); const { uploadFiles, removeFiles } = require("../module/files") const { koaBody } = require('koa-body'); const router = require('koa-router')(); const { jsonable } = require('../middleware/files') const fs = require("fs"); const { static_basepath, static_uploadpath } = require("../index.config"); const dayjs = require('dayjs'); /** 上传图片 */ router.post('/upload', async (ctx, next) => { const content_type = ctx.request.header['content-type'] if (content_type === 'application/json') { return jsonable()(ctx, next); } else if (content_type.includes('multipart/form-data')) { return koaBody({ // 支持多文件格式 multipart: true, formidable: { // 上传目录 uploadDir: path.join(static_basepath, static_uploadpath), // 保留文件扩展名 keepExtensions: true, //图片上传前的事件句柄 onFileBegin(formName, file) { ctx.request.formName = formName; //上传图片的根目录 const dirpath = path.join(static_basepath, static_uploadpath, dirname); const D = new Date(); //上传图片的二级目录(以‘年月日’格式为目录名) const dirname = dayjs(D).format("YYYYMMDD"); //检查上传图片的目录是否存在,若不存在,则创建 if (!fs.existsSync(dirpath)) { fs.mkdirSync(dirpath); } //改写文件存储的路径 file.filepath = path.join(static_basepath, static_uploadpath, dirname, file.newFilename) } } })(ctx, next) } }, uploadFiles); module.exports = router;
此处上传图片采用koa-router中间件router.post(path,middleware,callback)格式,middleware是中间件,该中间件代码逻辑为:
判断请求的content-type值,若为application/json,利用自定义的jsonable中间件处理上传,若为multipart/form-data,利用koa-body中间件处理上传。上传图片完成后执行uploadFiles回调函出,该函数用来处理数据库。
上传的图片如图:
2.编写自定义的jsonable中间件处理application/json的请求
新建 /middleware/files.js 文件,代码如下:
/** /middleware/files.js */ const path = require("path"); const fs = require("fs"); const { static_basepath, static_uploadpath } = require("../index.config"); const dayjs = require('dayjs'); const hexoid = require('hexoid'); /** 生成上传图片的目录(若没有该目录则创建) */ function fileDir() { const D = new Date(); const dirname = dayjs(D).format("YYYYMMDD"); const dirpath = path.join(static_basepath, static_uploadpath, dirname) if (!fs.existsSync(dirpath)) { fs.mkdirSync(dirpath); } return dirpath; } function jsonable() { return async (ctx, next) => { //请求参数 const params = ctx.request.body; //生成上传图片的目录 const dirpath = fileDir(); //获取文件数组 const files = Array.isArray(params.files) ? params.files : [params.files]; //获取文件名称数组 const filenames = params.filename || []; //当前被处理的文件 let file = null; //存储图片信息的数组 const filepathArr = []; //循环处理图片(该图片为base64数据) while (file = files.shift()) { //当前循环的文件名 const filename = filenames.shift() || '.png'; //生成上传图片的名称--为25为的随机名称 const uuid = hexoid(25)(); //图片base64数据主体 const base64Data = file.split('base64,') //图片的MIMEType,即Content-Type const mimetype = base64Data[0].replace(/^data:(.+?);/, '$1'); //图片扩展文件名 const ext = '.' + filename.split(".").reverse()[0]; //图片要存储的路径 const filepath = path.join(dirpath, uuid + ext); //将图片上传到指定路径 fs.writeFileSync(filepath, Buffer.from(base64Data[1], 'base64')); //获取图片信息对象 const f = fs.statSync(filepath) filepathArr.push({ filepath: filepath, mimetype, lastModifiedDate: f.birthtime }) } /** 改写请求参数以备后续使用(这里是模拟koa-body) Start*/ ctx.request.formName = 'file[]'; ctx.request.files = { [ctx.request.formName]: filepathArr }; /** 改写请求参数以备后续使用(这里是模拟koa-body) End*/ return next(); } } module.exports = { jsonable, }
该中间件用于处理Content-Type为application/json的上传图片请求,图片格式须为base64,可以是多图上传。
3.编写上传图片完成后的回调方法
新建 /module/files.js 文件,其代码如下:
/** /module/files.js */ const fs = require('fs') const path = require("path") const os = require("os"); const { URL } = require("url"); const { static_basepath } = require("../index.config"); const dayjs = require('dayjs'); /** 获取本机IPv4地址 */ function getIPAdress() { var interfaces = os.networkInterfaces(); for (var devName in interfaces) { var iface = interfaces[devName]; for (var i = 0; i { const url = `${host}${path.relative(static_basepath, item.filepath)}`; const myURL = new URL(url); v.push({ username: params?.username || "",//上传图片的用户 create_time: dayjs(item.lastModifiedDate).format("YYYY-MM-DD HH:mm:ss"),//上传时间 src: myURL.pathname,//上传路径 mimetype: item.mimetype,//MimeType值 group_by: params?.group || "",//组 }) }) } else {//单文件 const url = `${host}${path.relative(static_basepath, f.filepath)}`; const myURL = new URL(url); v.push({ username: params?.username || "", create_time: dayjs(f.lastModifiedDate).format("YYYY-MM-DD HH:mm:ss"), src: myURL.pathname, mimetype: f.mimetype, group_by: params?.group || "", }) } //整合sql语句 const keys = Object.keys(v[0]); const columns = keys.join(","); const values = v.map(item => `(${keys.map(key => typeof item[key] === "string" ? `'${item[key]}'` : item[key]).join(",")})`) const sql = `INSERT INTO user_uploads (${columns}) VALUES ${values.join(",")}`; //执行sql语句并响应请求 try { const r = await ctx.db.query(sql); ctx.response.status = 200; ctx.body = { message: "上传成功", code: 0, data: { path: v.map(item => item.src).join(",") } }; } catch (e) { ctx.response.status = 500; ctx.body = { message: e, code: 99 }; } } module.exports = { uploadFiles };
uploadFiles回调方法主要作用就是将上传的图片写入数据库,数据库表如下:
这里对于上传图片信息写入数据库是非必须的,这里我只是为了后续做图片管理打基础。
总结下我这里后端处理上传图片的逻辑:
1.在koa-router的中间件参数中处理图片上传,在其回调函数参数中处理上传图片的数据库
2.在处理图片上传中,根据请求数据的Content-Type给以不同的处理方案
后端处理删除图片
1.编写路由文件
在 /routes/files.js 文件中添加如下代码:
/** /routes/files.js */ /** 其他代码省略... */ router.post('/remove', removeFiles); module.exports = router;
2.编写删除图片回调方法
在 /module/files.js 文件中添加如下代码:
/** /module/files.js */ /** *** 查询该用户是否有删除该图片的权限--目前规则是只有上传者才有删除权限 *** paths 要删除的图片路径,String类型,多个时用逗号分隔 *** username 用户名 */ function filePermissions(ctx, next, paths, username) { const pathArr = paths.split(','); //将传来的paths去掉域名 const pathSql = pathArr.map(item => `'${item.replace(/^(http|https):\/\/[^\/]+/, "").replace(/^\s+|\s+$/gim, "")}'`).join(",") //sql:筛选paths的用户名和路径集合 const sql = `SELECT src,username FROM user_uploads WHERE src in (${pathSql})`; return new Promise(async (resolve, reject) => { try { const r = await ctx.db.query(sql); if (r) {//若路径存在,则继续 const v = r.filter(item => item.username !== username); //只要有一个权限不对,则拒绝,并将拒绝的sql行返回,否则,返回图片路径 if (v.length) { reject(v) } else { resolve(r.map(item => item.src)) } } else {//若路径都不存在 resolve('未查到该文件权限') } } catch (e) { reject(e) } }) } /** *** 删除服务器中的图片(物理删除),并将 *** fileArr 要删除图片路径,数组 */ function delFile(fileArr = []) { return new Promise((resolve, reject) => { let t = ""; const d = []; const errInfo = { atleastOneSucces: false,//true 至少一个删除成功 notExit: false,//至少一个文件不存在 otherExit: false,//删除文件时至少一个文件发生其他错误 } /** 循环删除文件 */ while (t = fileArr.pop()) { const delPath = path.join(static_basepath, t); try { /** * @des 判断文件或文件夹是否存在 */ if (fs.existsSync(delPath)) { fs.unlinkSync(delPath); d.push({ message: "删除成功", path: t }) errInfo.atleastOneSucces = true; } else { d.push({ errMsg: "文件不存在", path: t }); errInfo.notExit = true; } } catch (error) { d.push({ errMsg: error, path: t }); errInfo.otherExit = true; } } if (errInfo.atleastOneSucces) { if (errInfo.notExit|| errInfo.otherExit) { return resolve({ message: "部分删除成功", code: 2, data: d }); } else { return resolve({ message: "删除成功", code: 0, data: d }); } } else if (!errInfo.atleastOneSucces) { return reject({ message: "删除失败", code: 3, data: d }); } }) } /** ***在数据库删除图片信息 ***pathArr 要删除图片路径,数组 */ function delUserUploadsTableRow(ctx, next, pathArr) { const pathSql = pathArr.map(item => `'${item.replace(/^(http|https):\/\/[^\/]+/, "").replace(/^\s+|\s+$/gim, "")}'`).join(","); const sql = `DELETE FROM user_uploads WHERE src in (${pathSql})`; return new Promise(async (resolve, reject) => { try { await ctx.db.query(sql); resolve(true); } catch (e) { reject(e) } }) } /** 删除图片总方法 */ async function removeFiles(ctx, next) { const params = ctx.request.body; //要删除的图片路径 const paths = params.path; //当前操作用户 const username = params.username; let filepathArr = []; /** 查看当前用户是否有权限 START*/ try { filepathArr = await filePermissions(ctx, next, paths, username) } catch (e) { if (Array.isArray(e)) { ctx.response.status = 403; ctx.body = { message: '没有权限', code: 1, data: e }; } else { ctx.response.status = 500; ctx.body = { message: e, code: 99 }; } return; } /** 查看当前用户是否有权限 END*/ /** 物理删除图片 START*/ //存储删除信息 let fileDeleteInfo; try { fileDeleteInfo = await delFile(filepathArr); } catch (e) { fileDeleteInfo = e; ctx.response.status = 500; ctx.body = { message: e, code: 99 }; return; } /** 物理删除图片 END*/ /** 在数据库删除已物理删除图片的信息 START*/ try { const successDelArr = fileDeleteInfo.data.filter(item => item.message).map(item => item.path); await delUserUploadsTableRow(ctx, next, successDelArr); } catch (e) { fileDeleteInfo = { message: e, code: 4 }; } /** 在数据库删除已物理删除图片的信息 END*/ ctx.body = fileDeleteInfo; }
番外
formidable.js
上面后端代码处理formData数据使用的是koa-body中间件,而koa-body是基于formidable.js,那么就可以使用formidable.js替代koa-body:
安装formidable.js
npm install formidable@v3
代码如下:
/** /routes/files.js */ const path = require("path"); const { uploadFiles, removeFiles } = require("../module/files") const router = require('koa-router')(); const { IncomingForm } = require('formidable');//引入formidable const { jsonable } = require('../middleware/files') const fs = require("fs"); const { static_basepath, static_uploadpath } = require("../index.config"); const dayjs = require('dayjs'); router.post('/upload', async (ctx, next) => { const content_type = ctx.request.header['content-type'] if (content_type === 'application/json') { return jsonable()(ctx, next); } else if (content_type.includes('multipart/form-data')) { /** formidable插件使用 START */ //配置项 const p = { //支持多文件上传 multipart: true, // 上传目录 uploadDir: path.join(__dirname, '../public/uploads'), // 保留文件扩展名 keepExtensions: true, } const form = new IncomingForm(p); //上传前的事件 form.on('fileBegin', (formName, file) => { ctx.request.formName = formName; const dirname = dayjs().format("YYYYMMDD"); const dirpath = path.join(static_basepath, static_uploadpath, dirname) if (!fs.existsSync(dirpath)) { fs.mkdirSync(dirpath); } file.filepath = path.join(static_basepath, static_uploadpath, dirname, file.newFilename) }) await new Promise((resolve, reject) => { //解析Node.js Request对象 form.parse(ctx.req, (err, fields, files) => { if (err) { reject(err); return; } ctx.request.files = files; ctx.request.body = {}; for (let i in fields) { ctx.request.body[i] = fields[i][0] } resolve({ fields, files }); }); }); return next(); /** formidable插件使用 START */ } }, uploadFiles); router.post('/remove', removeFiles) module.exports = router;
本文实现的后端方法不仅能上传图片,也可以上传其他文件,且可以多文件上传。
结语
新纪元的到来让世界勃勃生机,而角角落落依然遍布着草履虫们,因为它们仍旧适应这个世界。
参考资料
Axios 中怎么上传文件(Upload File)?上传方法有哪几种?
思否:在Koa.js中实现文件上传的接口
简书:NodeJs koa2实现文件上传
CSDN:koa-body koa2 使用 koa-body 代替 koa-bodyparser 和 koa-multer
CSDN:koa-body4接收formData数据
CSDN:koa-body 文件上传自定义文件夹及文件名称
稀土掘金:原生nodejs 处理文件上传
简书:node原生处理前端传送的数据
稀土掘金:上传文件时获取上传进度
W3cways:koa + formidable实现文件上传
npm:koa-body
npm:hexoid
npm:node-mime-types
CitCode–GitHub镜像