背景
在平常的工作中,经常会遇到需要将本地项目文件同步到远端服务器的情况,所以每次遇到都需要考虑如何将文件上传到服务器上。我使用的系统不一样,就需要考虑使用不同的上传方式。
虽然现在有很多文件上传的工具,不过有些库依赖系统,如rsync或scp这种库一般在linux环境中使用,有些库需要依赖安装客户端和服务端,如ftp。作为一个开发人员,要有折腾的精神,所以我决定写一个基于web的文件上传工具,方便不同系统环境中可以轻松地实现文件上传。
把这个工具封装成一个npm库,并通过执行一个简单的命令来实现将本地文件上传到远端服务器的功能。
http上传文件实现原理
使用原生http库是实现文件上传最简单直接的方式,大概分为以下几个步骤:
- 创建http服务器,监听客户端请求;
- 解析http请求,获取上传文件的相关信息,如文件名、文件大小等;
- 创建一个可写流,将接收到的文件数据写入到服务器的临时文件中;
- 监听文件写入完成事件,将临时文件移动到指定的目录,完成上传;
本文主要使用两种方式实现上传,一种是multipart/form-data类型,这种方式支持携带文件元数据。另一种是application/octet-stream,只传输纯粹的二进制数据,不对数据进行处理,这两种都可以实现文件上传。
使用表单实现上传
当我们使用POST方式的表单向服务器发送HTTP请求时,表单的参数会被写入到HTTP请求的BODY中,根据不同的contentType,body中存放的数据格式也不相同。
本文主要使用了multipar
text/plain
这种请求体类型是最简单的一种,传输纯文本数据,请求体中的数据以纯文本形式进行编码,没有键值对的结构。适用于简单的文本数据传输,如纯文本文件或简单的文本消息。如以下代码,请求到服务器端时,请求体内是一段文本。
fetch('http://127.0.0.1:3000/submit', {
headers: {
content-type: 'text/plain',
body: '我只是一段文本'
}
})
application/x-www-form-urlencoded
这种请求体类型是最常见的,用于传输表单数据。请求体中的数据以键值对的形式进行编码,每个键值对之间使用"&"符号分隔,键和值之间使用"="符号分隔。适用于传输简单的表单数据,如登录表单、搜索表单等。
fetch('http://127.0.0.1:3000/submit', {
headers: {
content-type: 'application/x-www-form-urlencoded',
body: 'username=admin&password=123456'
}
})
multipart/form-data
这种请求体类型用于上传文件或传输复杂的表单数据。请求体中的数据以多个部分(part)的形式进行编码,每个部分之间使用boundary进行分隔。每个部分由一个头部和一个数据部分组成,头部包含了部分的元数据信息,数据部分则包含了具体的数据。适用于上传文件或包含文件的表单数据。
在发起请求时,content-Type中会创建一个随机数字串作为内容之间的分隔符,以下为一个请求头示例:
Content-Type: multipart/form-data; boundary=--------------------------585592033508456553585598
请求体内容类似以下方式:
----------------------585592033508456553585598
Conent-Disposition: form-data; name="username"
admin
----------------------585592033508456553585598
Conent-Disposition: form-data; name="password"
123456
----------------------585592033508456553585598
Conent-Disposition: form-data; name=""; filename="hw.txt"
Content-type: text/plain
hello world!
----------------------585592033508456553585598
Conent-Disposition: form-data; name=""; filename="avatar.png"
Content-type: image/png
?PNG
[图片数据]
每个片段都是以Content-Type中的随机串做分隔,每个片段都可以指定自己不同的文件类型。 比如上面的示例中就包含了文本文件,图片,表单字段。
使用数据流上传
发送请求时,将Content-Type设置成”application/octet-stream“,这是一种通用的二进制数据传输格式,它不对数据进行特殊处理或解析。通常用于传输不可见字符、二进制文件或未知类型的数据。进行传输时,数据会被视为纯粹的二进制流,没有特定的结构或格式。这种方式不支持在请求体中携带文件的元数据,仅仅是将文件内容作为二进制数据传输。
实现功能
文件上传npm库,可以在服务器端使用nodejs快速启动一个web服务,将本地文件上传到服务器,可以跨平台使用。命令行参数格式与scp
上传文件的方式一样。不过未实现将服务器文件同步到本地的能力。。。
启动web服务器
使用以下命令快速创建一个web服务,ip
监听的地址默认为'0.0.0.0',port
监听的端口号,默认为3000.
dwsync server [ip] [port]
上传文件
使用以下命令上传文件到服务器。
-t
: 设置连接服务器的类型,http
|ftp
,默认值:http
;
本地路径
: 要上传的本地文件路径;
用户名
: 连接ftp服务时需要;
域名
: 服务器地址;
端口
: 服务器端口;
服务器路径
: 要保存的服务器路径,连接服务器类型是http服务时,传入服务器绝对路径;连接服务器类型是ftp时,传入为相对路径;
dwsync [-t http|ftp] <本地路径> 用户名@域名:端口:<服务器路径>
连接服务器类型为ftp时,执行命令后,会显示输入密码框: 如下图所示:
实现方式
主要包含三部分内容,在服务器端启动一个web服务器,用来接收请求并处理。 在客户端执行命令请求指定服务器接口,将本地文件传输到服务器上。 如果服务器端有ftp服务器,那么可以直接在本地连接ftp服务器上传文件。
http服务器
服务器端是运行在服务器上的一个web服务,这个web服务提供三个接口能力。
创建目录
判断当前服务器上是否存在当前目录,如果不存在些创建目录,并通知客户端。创建成功或者目录已存在,则发送响应”1“,如果创建目录失败,则返回”0“,并中止上传。 以下为代码实现:
function handleCreateDir(req, res) {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
try {
// 由于在请求时,设置了Content-Type为application/json,所以这里需要解析一下JSON格式。
const filepath = JSON.parse(body);
if (fs.existsSync(filepath)) {
logger.info(`Directory already exists: ${filepath}`);
res.end('1');
return;
}
fs.mkdir(filepath, (err) => {
if (err) {
logger.error('Error create dir:', err);
res.statusCode = 500;
res.end('0');
} else {
logger.info(`Directory created: ${filepath}`);
res.end('1');
}
});
} catch (error) {
logger.error('Error create dir:', error);
res.statusCode = 500;
res.end('0');
}
});
}
上传文件
需要两个参数,remoteFilePath用来告诉服务器我要保存到服务器的路径。file是上传的文件内容。
上传文件使用了第三方库formidable
中的IncomingForm类来解析传入的请求并获取上传的文件。
启动web服务时,会设置一个临时目录,用来保存上传到服务器的文件,上传完成后,会根据传入的服务器路径字段,将文件移动到对应服务器指定文件夹下。
具体实现代码如下:
function handleFileUpload(req, res) {
const form = new IncomingForm({ allowEmptyFiles: true, minFileSize: 0 });
form.uploadDir = cacheDir;
form.parse(req, (err, fields, files) => {
let { remoteFilePath } = fields;
remoteFilePath =
remoteFilePath && remoteFilePath.length > 0
? remoteFilePath[0]
: remoteFilePath;
if (err) {
logger.error('Error parsing form:', err);
res.statusCode = 500;
res.end('Internal Server Error');
} else {
let uploadedFile;
if (files.file && Array.isArray(files.file)) {
uploadedFile = files.file[0];
} else {
logger.error('no file');
res.statusCode = 500;
res.end('Internal Server Error');
return;
}
fs.rename(uploadedFile.filepath, remoteFilePath, (err) => {
if (err) {
logger.error('Error renaming file:', err);
res.statusCode = 500;
res.end('Internal Server Error');
} else {
logger.info(`File saved: ${remoteFilePath}`);
res.end('Upload complete');
}
});
}
});
}
上传大文件
一般情况下,web服务对上传的文件大小是有限制的,所以一些特别大的文件我们就需要做分片上传,在这个工具中,对大文件的处理我使用了上传二进制的方式。
上传二进制数据时,会把文件信息封装到header头里,在header里设置了 fileSize是文件的总字节数,startByte是本次上传的起始下标,endByte是本次上传的结束下标。 使用fs.createWriteStream创建一个追加的写入流,将数据块写入指定的RemoteFilePath。监听请求对象上的数据事件并将接收到的数据写入文件。 当请求数据获取完毕后,判断是否文件的总字节流与本次的结束下标是否一致,如果一致则发送文件上传成功的响应,否则发送分片上传成功响应。
代码实现:
function handleChunkFileUpload(req, res) {
const remoteFilePath = decodeURIComponent(req.headers['remote-file-path']);
const fileDir = path.dirname(remoteFilePath);
if (!fs.existsSync(fileDir)) {
fs.mkdirSync(fileDir);
}
const fileSize = parseInt(req.headers['file-size']);
const startByte = parseInt(req.headers['start-byte']);
const endByte = parseInt(req.headers['end-byte']);
const writableStream = fs.createWriteStream(remoteFilePath, {
flags: 'a',
start: startByte,
});
req.on('data', (data) => {
writableStream.write(data);
});
req.on('end', () => {
writableStream.end();
if (endByte === fileSize - 1) {
logger.info(`File saved: ${remoteFilePath}`);
res.end('File uploaded successfully');
} else {
res.end('Chunk uploaded successfully');
}
});
}
http上传客户端实现
http客户端上传使用第三方库axios实现上传功能,使用form-data库创建表单对象。
要上传文件到服务器,我们需要知道以下信息:
- 要上传到的服务器地址
- 本地文件路径
- 要上传的服务器路径
遍历本地文件
首先我们使用fs.statSync
方法检查本地路径是目录还是文件。如果是一个文件,则请求上传文件接口。如果是目录则会遍历文件夹内所有的文件,递归调用upload方法处理。
// 递归上传文件或文件夹
async upload(filePath, remoteFilePath) {
const stats = fs.statSync(filePath);
if (stats.isFile()) {
//...
} else if (stats.isDirectory()) {
//...
}
}
当上传的是文件并且文件大小超过200MB时,会使用大文件上传接口通过上传二进制数据流的方式分片上传文件。如果文件小于200MB,则使用form-data方式上传文件到服务器。
// 递归上传文件或文件夹
async upload(filePath, remoteFilePath) {
const stats = fs.statSync(filePath);
if (stats.isFile()) {
if (stats.size > this.maxChunkSize) {
await this.uploadChunkFile(filePath, remoteFilePath, stats.size);
} else {
await this.uploadFile(filePath, remoteFilePath);
}
} else if (stats.isDirectory()) {
//...
}
}
本地路径是目录时,首先会请求服务器端的http://127.0.0.1:3000/mkdir
方法,确保当前传入的remoteFilePath
路径已经在服务器上创建。接口返回1
之后,遍历当前本地目录,生成对应的服务器文件路径,递归调用自身,继续下次遍历。
try {
// 在服务器创建目录
const response = await axios.post(
`${this.protocol}://${this.host}:${this.port}/mkdir`,
remoteFilePath,
{
headers: { 'Content-Type': 'application/json' },
}
);
// 创建成功
if (response.data === 1) {
// 读取本地文件列表
const list = fs.readdirSync(filePath);
for (let index = 0; index < list.length; index++) {
const file = list[index];
const childFilePath = path.join(filePath, file);
// 拼接服务器路径
const childRemoteFilePath = path.join(remoteFilePath, file);
// 递归调用
await this.upload(childFilePath, childRemoteFilePath);
}
} else {
console.error(`创建远程文件夹失败: ${remoteFilePath}`);
return;
}
} catch (error) {
console.error(`创建远程文件夹失败: ${remoteFilePath}`);
console.error(error.message);
return;
}
文件上传功能
文件上传使用第三方库form-data
创建表单数据对象,并将服务器地址remoteFilePath
当前参数传递到服务器。
async uploadFile(filePath, remoteFilePath) {
const formData = new FormData();
formData.append('remoteFilePath', remoteFilePath);
formData.append('file', fs.createReadStream(filePath));
const progressBar = new ProgressBar(
`Uploading ${filePath} [:bar] :percent`,
{
total: fs.statSync(filePath).size,
width: 40,
complete: '=',
incomplete: ' ',
}
);
const response = await axios.post(
`${this.protocol}://${this.host}:${this.port}/upload`,
formData,
{
headers: formData.getHeaders(),
onUploadProgress: (progressEvent) => {
progressBar.tick(progressEvent.loaded);
},
}
);
console.log(`Upload complete: ${response.data}`);
}
大文件上传
大文件上传使用二进制数据流发送到服务器端,使用文件大小除以最大限制值获取需要发起请求的数量,依次发送到服务器端。
const totalChunks = Math.ceil(fileSize / this.maxChunkSize);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
await this.uploadChunk(filePath, remoteFilePath, fileSize, chunkIndex, progressBar);
}
每次上传发起请求都需要这四个字段:
Remote-File-Path
:服务器端保存路径
File-Size
:文件总字节大小
Start-Byte
:本次请求起始字节下标
End-Byte
:本次请求结束字节下标
参数通过自定义header提交到服务器端。
async uploadChunk(filePath, remoteFilePath, fileSize, chunkIndex, progressBar) {
// 计算本次上传起始位置
const startByte = chunkIndex * this.maxChunkSize;
// 计算本次上传结束位置,如果超过文件长度,则使用文件长度-1
const endByte = Math.min((chunkIndex + 1) * this.maxChunkSize - 1, fileSize - 1);
const headers = {
'Content-Type': 'application/octet-stream',
'Remote-File-Path': encodeURIComponent(remoteFilePath),
'File-Size': fileSize,
'Start-Byte': startByte,
'End-Byte': endByte,
};
// 创建二进制数据流
const chunkStream = fs.createReadStream(filePath, {
start: startByte,
end: endByte,
});
// 发起请求
await axios.post(
`${this.protocol}://${this.host}:${this.port}/uploadchunk`,
chunkStream,
{
headers,
onUploadProgress: (progressEvent) => {
const curr = startByte + progressEvent.loaded;
progressBar.tick(curr);
},
});
}
使用form-data
表单也可以实现大文件上传,form-data
类型每个片段都可以自定类型。实现方式没什么区别。
ftp客户端实现
这个使用第三方库ftp
实现上传到ftp服务器,ftp同样使用递归遍历本地上传路径的方式,依次将文件上传到远端服务器。
创建ftp连接
根据ftp服务器的地址及用户名、密码信息创建ftp连接。ftp连接服务器成功后,调用上传方法。
// 创建FTP客户端并连接到服务器
const client = new ftp();
client.connect({
host: this.host,
port: this.port,
user: this.user,
password: this.pwd,
});
client.on('ready', async () => {
console.log('已连接到FTP服务器...');
await this.uploadToFTP(this.localPath, this.remotePath, client);
// 断开连接
client.end();
});
client.on('progress', (totalBytes, transferredBytes, progress) => {
console.log(`上传进度: ${progress}%`);
});
client.on('error', (err) => {
console.error(`FTP连接错误: ${err}`);
});
递归遍历路径
递归遍历路径,如果当前路径是文件,调用上传方法。如果当前路径是目录,则递归调用。
// 递归上传文件或文件夹
async uploadToFTP(filePath, remoteFilePath, client) {
const stats = fs.statSync(filePath);
if (stats.isFile()) {
try {
await this.uploadFile(filePath, remoteFilePath, client);
console.log(`上传文件成功: ${filePath}`);
} catch (error) {
console.error(`文件上传失败: ${filePath}`);
console.error(error);
}
} else if (stats.isDirectory()) {
try {
await this.createDir(remoteFilePath, client);
} catch (error) {
console.error(`创建远程文件夹失败: ${remoteFilePath}`);
console.error(error);
return;
}
const list = fs.readdirSync(filePath);
for (let index = 0; index < list.length; index++) {
const file = list[index];
const childFilePath = path.join(filePath, file);
const childRemoteFilePath = path.join(remoteFilePath, file);
await this.uploadToFTP(childFilePath, childRemoteFilePath, client);
}
}
}
上传文件
这个上传很简单,调用封装好的put方法,将文件发送到ftp服务器。
uploadFile(filePath, remoteFilePath, client) {
return new Promise((resolve, reject) => {
client.put(filePath, remoteFilePath, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
上传进度展示
上传进度使用第三方库progress
在命令行界面显示上传进度及文件,可以方便的跟踪上传情况。使用axios发送http请求时,可以使用onUploadProgress
方法获取进度并使用progress
展示出来。
使用如下代码创建一个进度实例对象,第一个参数是输出的结构,第二个参数是参数配置。
const progressBar = new ProgressBar(
`Uploading ${filePath} [:bar] :percent`,
{
total: fs.statSync(filePath).size,
width: 40,
complete: '=',
incomplete: ' ',
}
);
在axios中展示使用:
// 发起请求
await axios.post(
'http://127.0.0.1/uploadchunk',
chunkStream,
{
headers,
onUploadProgress: (progressEvent) => {
progressBar.tick(progressEvent.loaded);
},
});
}
结语
本文基本上实现了一个跨平台的命令行文件上传工具的功能,只需要使用命令即可实现本地文件上传至服务器。