之前在开发微信小程序的时候,发现官方给每个小程序分配了5g的免费云存储空间和每个月5g的cdn流量(免费版):
在小程序的开发后台可以查看云存储上的文件,文件本质上是存在cdn上的,每个文件都提供了专属的downLoad url,靠着这个url我们就可以下载部署在云端的文件,也就是说上传的文件自带cdn加速。
5G的空间不算少,自己的小程序用不到额外的云存储资源,这个资源拿来给自己搭建一个私有云盘岂不美哉?以后自己的一些小文件就可以放在上面,方便存储和下载。诸位如果没有开发过小程序也没有关系,在微信公众平台上随便申请个工具人小程序,然后开启云开发
即可,我们只是白嫖云存储空间。项目地址
见文末。
要完成我们的设想,我们先罗列下我们需要哪些功能:
小程序云存储的相关api支持服务器端调用,不支持浏览器直接调用,所以为了操作云存储的相关api,我们需要开启一个中继的node服务作为服务器,顺便管理我们的文件列表。
整个系统的工作流应该是这样的:在我们的前端服务通过用户交互,上传文件到中继的node服务上,node服务器将接收到的文件上传给小程序的云存储空间,获取返回的文件的相关信息(主要是download url),同时在数据库内维护文件列表的相关信息(直接存在小程序对应的数据库中即可)。前端服务会请求后端获取云存储中的文件列表,通过用户的交互可对各个文件进行删除和下载等操作(实际上是向node服务器发送请求,由node服务器调用官方的各种api来对云端的数据进行处理)。
在工具链的选择上,采取react + antd + typescript的技术方案,后端服务使用node + express。
首先我们从数据流的源头开始,开始搭建文件核心上传部分index.tsx
:
import React, {
useState, useEffect, useReducer } from 'react';
import * as s from './color.css';
import withStyles from 'isomorphic-style-loader/withStyles';
import {
Layout, Upload, Card, Button, message, Table, Progress, Spin } from 'antd';
import {
UploadOutlined } from '@ant-design/icons';
import {
upload } from '@utils/upload';
import {
UploadFile, UploadChangeParam } from 'antd/lib/upload/interface';
import {
fileObj, parseList, columns, FileListAction, ProgressObj, ProgressAction } from './accessory';
const {
Header, Content, Footer } = Layout;
// 省略部分依赖
function ShowComponent() {
// 文件上传列表的hooks
const [fileList, setFList] = useReducer(listReducer, []);
// 省略无关代码
// ......
async function handleChange(info: UploadChangeParam<UploadFile<any>>) {
const {
fileList: newFileList, file } = info;
// 上传文件的核心逻辑
const ans = await upload(info);
const {
fileData = {
} } = ans;
if (fileData.fileName) {
setFList({
type: 'update', payload: Object.assign(fileData, {
key: fileData._id }) });
message.success(`${
info.file.name} 上传成功。`);
} else {
message.error(`${
info.file.name} 上传失败。`);
return;
}
}
return (
<Layout className={
s.layout}>
<Header>
<div className={
s.title}>自己的网盘</div>
</Header>
<Content style={
{
padding: '50px 50px' }}>
<div className={
s.siteLayoutContent}>
<Upload
customRequest={
() => {
}}
onChange={
handleChange}
showUploadList={
false}
multiple={
true}
>
<Button>
<UploadOutlined /> Click to Upload
</Button>
</Upload>
</div>
</Content>
</Layout>
)
}
export default withStyles(s)(ShowComponent);
这部分的逻辑很简单,主要是通过react+antd搭建UI,使用antd的Upload
控件完成上传文件的相关交互,将获取到的文件对象传递给封装好的upload
函数,接下来我们来看看upload.tsx
中的逻辑:
import {UploadFile, UploadChangeParam } from 'antd/lib/upload/interface';
import { reqPost, apiMap, request, host } from '@utils/api';
import { ProgressObj, ProgressAction } from '../entry/component/content/accessory';
const SIZE = 1 * 1024 * 1024; // 切片大小
// 生成文件切片
function createFileChunk(file: File | Blob | undefined, size = SIZE) {
if (!file) {
return [];
}
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
// 对字节码进行切割
fileChunkList.push({ file: file.slice(cur, cur + size) });
cur += size;
}
return fileChunkList;
}
interface FileObj extends File {
name: string;
}
// 发送单个的文件切片
export async function uploadFile(params: FormData, fileName: string) {
return request(host + apiMap.UPLOAD_FILE_SLICE, {
method: 'post',
data: params,
});
}
// 给服务器发送合并切片的逻辑
export async function fileMergeReq(name: string, fileSize: number) {
return reqPost(apiMap.MERGE_SLICE, { fileName: name, size: SIZE, fileSize: fileSize });
}
export async function upload(info: UploadChangeParam>) {
// 获取切片的文件列表
const fileList = createFileChunk(info.file.originFileObj);
if (!info.file.originFileObj) {
return '';
}
const { name: filename, size: fileSize } = info.file.originFileObj as FileObj;
// 生成数据包list
const dataPkg = fileList.map(({ file }, index) => ({
chunk: file,
hash: `${filename}-${index}` // 文件名 + 数组下标
}));
// 通过formdata依次发送数据包
const uploadReqList = dataPkg.map(({ chunk, hash}) => {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('hash', hash);
formData.append('filename', filename);
return formData
});
const promiseArr = uploadReqList.map(item => uploadFile(item, filename));
await Promise.all(promiseArr);
// 全部发送完成后发送合并切片的请求
const ans = await fileMergeReq(filename, fileSize);
callBack({ type: 'delete', fileName: filename });
return ans;
}
这里的逻辑并不复杂,核心是思想是将用户上传的文件切成每个1M的文件切片,并做好标记,将所有的文件切片送到服务器,服务器接收到所有的切片后告知前端接收完成,前端发送合并请求,告知服务器可以将所有的文件切片依据做好的标记合并成原文件。
接下来我们看看服务器端与之配合的代码:
let ownTool = require('xiaohuli-package');
let fs = require('fs');
const request = require