一、背景介绍
在开发过程中,常规的项目部署都是通过 gitlab runner 或者 jenkins 等工具,通过配置 ssh key,将代码拉取下来,然后通过 shell 脚本进行打包,上传到服务器,最后通过 shell 脚本进行部署,这些是常规的 CI/CD 流程。但是,这些工具的配置和使用相对复杂,而且需要一定的技术知识。
有些项目没有配置 CI/CD 或者公司内没有完备的部署流程,只能我们自己来操作。窃以为直接把 build 之后的 dist 文件包,上传/下载/删除文件到服务器,更方便直接,虽然看起来没有那么规范😜。更多还是看使用场景吧,如果是个人项目或者一些小项目,就没有必要去部署繁琐的 CI/CD ,毕竟像 jenkins 这类工具,最终也是要生成 dist 文件,放到 nginx 配置的根目录下,由 nginx 托管。
恰好本司有这个需求场景,就轮到大名鼎鼎的 ssh2-sftp-client 出场了。
二、关键工具 ssh2-sftp-client
ssh2-sftp-client 是一个为 Node.js 设计的 SFTP 客户端,它基于 SSH2 模块,提供了高级别的抽象和基于 Promise 的 API。
几个关键点:
1.SSH2: 这里的 SSH2 不是指协议,而是指一个 node.js 为 SSH2 client(SSH2客户端)的实现。
github/SSH2
2.SFTP客户端: SSH File Transfer Protocol的缩写,安全文件传送协议
优点:
Promise支持:所有方法均返回Promise,便于异步编程和错误处理。- 丰富的
API:提供了从基本的文件列表查询到复杂的目录上传下载等全方位功能。 - 性能优化:针对
Node.js 18及以上版本进行了性能优化,特别是在处理大量文件时表现更佳。 - 跨平台兼容:虽然主要使用
Unix风格的路径分隔符,但也支持通过配置适应Windows服务器。
简单来说:
ssh2-sftp-client 是基于 SSH2 库进行了封装,让 文件传输 相关的方法变得更为简洁,还支持了 Promise 。
三、需求场景分析与实现
直接向服务器传输文件,一般使用 SCP、FTP 工具,或者是使用 ssh 通过命令行操作,这两种方式,一个是需要安装第三方软件,一个是没有 gui 界面,还得记忆命令行指令,开发体验不是很友好。
程序猿就要用程序猿的方式解决问题 🐒
我决定自己写一个工具脚本,通过 ssh2-sftp-client 库,实现一个简单的文件上传和删除功能,这是最终版的截图:
vscode:
mac shell:
怎么样是不是很炫酷,高大上🤭
下面我通过三步,来详细讲解一下实现过程。
- 手动操作 &
ssh命令行模式 - 使用
ssh2-sftp-client编程实现 - 美化界面,完全版
v1 v2
1. 手动操作 & ssh 命令行模式
手动模式一般是这样的:
npm run build- 打开
xftp软件 - 进入相应的目录
- 删除现存的
html目录 - 把打包出来的
dist重命名为html - 在
xftp上传新打包的目录
也可以是这样:
npm run build- 登录公司服务器/堡垒机,输入帐号密码
- 进入相应的目录
- 删除现存的
dist目录 - 把打包出来的
dist拖动到指定目录
命令行模式:
ssh zhangsan@root@192.168.10.220@qytjms.pekingulaw.cn -p 2345
有时会出现错误🙅,本人就遇到了🙉,比如:
这是当你使用 SSH 连接到一个新的主机时,系统会提示你验证该主机的真实性。因为 SSH 客户端没有该主机的公钥指纹,出于安全考虑,需要你确认是否继续连接。
继续连接:
如果你确认该主机是可信的,并且指纹是正确的,可以输入 yes 以继续连接。输入后,SSH 客户端会将该主机的公钥添加到你的 ~/.ssh/known_hosts 文件中,以便下次连接时不会再显示此警告。
再次输入命令,添加 ssh-rsa 密钥
ssh -o HostKeyAlgorithms=+ssh-rsa zhangsan@root@192.168.10.220@qytjms.pekingulaw.cn -p 2345
之后你将能够访问目标主机。
命令行虽然快捷,但是可能会出现一些不可知的问题。
2. 使用 ssh2-sftp-client 编程实现
自己写一个工具脚本,用程序编码实现上面手动模式的1、2、3、4...步。
安装依赖:
建议 node 版本18.x,本人使用的是 18.20.5 的18最新稳定版。
pnpm install ssh2-sftp-client -D
引入依赖并实例化:
const SftpClient = require('ssh2-sftp-client');
const sftp = new SftpClient();
连接服务器:
sftp 支持 Promise,本人习惯 使用 async/await,所以使用 await 关键字。
await sftp.connect({
host: 'qytjms.pekingulaw.cn',
port: 2345,
username: 'zhangsan',
password: '123456',
algorithms: { // 如有需要
serverHostKey: ['ssh-rsa'],
},
});
列出远程目录中的文件:
const remotePath = '/home/zhangsan/'; // 远程目录
const fileList = await sftp.list(remotePath);
console.log(fileList);
如果你看到命令行输出这些,说明我们已经连接成功了。
完成所有文件操作后,别忘了断开与SFTP服务器的连接:
await sftp.end()
ssh2-sftp-client支持多种操作,比如下载文件、上传文件、删除文件、重命名文件等等。
// 上传文件
await sftp.put('/local/path/to/file.txt', '/remote/path/to/file.txt')
// 下载文件
await sftp.get('/remote/path/to/file.txt', '/local/path/to/file.txt')
// 列出目录内容
await sftp.list('/remote/path')
// 删除文件
await sftp.delete('/remote/path/to/file.txt')
// 上传文件夹
await sftp.uploadDir('本地目录','远程目录')
// 下载服务器的文件夹
await sftp.downloadDir('远程目录','本地目录')
// 重命名服务器文件夹
await sftp.rename('远程目录','远程新目录')
......
因为我们的功能是上传,删除服务器dist文件夹,同时要考虑第一次服务器没有dist文件的情况,再加上一些输出日志,所以代码如下:
const SftpClient = require('ssh2-sftp-client');
const path = require('path');
const sftp = new SftpClient();
const localPath = path.resolve(__dirname, './dist')
const remotePath = '/Default/192.168.10.220/project/front/admin'
const remotePathDir = `${remotePath}/dist`
const configFtp = {
host: 'qytjms.pekingulaw.cn', // 使用你的测试 host
port: 2345, // 使用你的测试端口
username: 'zhangsan', // 使用拼接后的用户名
password: '123456', // 使用你的密码
algorithms: { // 添加支持 ssh-rsa 算法 如有需要
serverHostKey: ['ssh-rsa'],
},
// debug: console.log // 输出调试信息
}
const main = async () => {
try {
await sftp.connect(configFtp);
console.log('Connected to SFTP server successfully!');
// 文件操作
if (await sftp.exists(remotePath)) {
// 第一次上传没有dist文件夹,创建dist文件夹
if (!(await sftp.exists(remotePathDir))) {
// 递归创建目录
await sftp.mkdir(remotePathDir, true);
console.log(`Directory created: ${remotePathDir}`);
} else {
// 删除dist文件夹
await sftp.rmdir(remotePathDir, true)
console.log('File deleted successfully')
}
// 上传文件
await sftp.uploadDir(localPath, remotePathDir);
console.log('File uploadDir successfully');
}
} catch (err) {
// 错误处理
console.error('Error connecting to SFTP server:', err);
} finally {
// 关闭连接(别忘了)
await sftp.end();
}
};
main();
至此,一个基本的上传,下载就完成了。
这样就完成了吗?还没有,作为一个代码geek患者,当然要继续优化啦。
3. 美化界面,完全版v1 v2
上传&删除文件,需要显示进度条、百分比、文件数,这样才能知道我的进度到哪里了,否则只能是干等,也没有任何提示。
内心OS: 程序出错了?还是在上传?还是下载?还是什么情况??🤔
要想展示进度条,首先要知道操作的文件数量,然后才能知道进度。这里需要用到 fs path 模块,获取文件数量。
const fs = require('fs');
const path = require('path');
// 计算一个文件夹中文件总数
const calculateTotalSize = (dir) => {
let totalSize = 0;
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory()) {
totalSize += calculateTotalSize(filePath);
} else {
totalSize += stats.size;
}
}
return totalSize;
};
同样,我们还需要一个进度条,这里用代码实现一个简单的进度条。
长这个样子 [======-----] 60%
// 展示进度条
const displayProgressBar = (progress) => {
const barLength = 40; // 进度条长度
const completed = Math.round(progress * barLength);
const bar = '='.repeat(completed) + '-'.repeat(barLength - completed);
process.stdout.write(`\r[${bar}] ${(progress * 100).toFixed(2)}%`);
};
在上传中,我们使用 let uploadedFiles = 0 记录初始进度为0,然后每上传一个文件,uploadedFiles 加一,然后计算进度,调用 displayProgressBar(progress) 显示进度条。文件的查找都是递归调用的,因为文件夹中可能还有文件夹。
const localFiles = await getLocalFiles(localDir);
const totalFiles = localFiles.length;
let uploadedFiles = 0;
const uploadRecursive = async (localPath, remotePath) => {
const items = await fs.readdir(localPath, { withFileTypes: true });
for (const item of items) {
const localItemPath = path.join(localPath, item.name);
const remoteItemPath = `${remotePath}/${item.name}`;
if (item.isDirectory()) {
// 如果是目录,递归创建远程目录并上传
if (!(await sftp.exists(remoteItemPath))) {
await sftp.mkdir(remoteItemPath, true);
}
await uploadRecursive(localItemPath, remoteItemPath);
} else {
// 如果是文件,上传文件并更新进度
await sftp.fastPut(localItemPath, remoteItemPath);
uploadedFiles += 1;
displayProgressBar(uploadedFiles / totalFiles);
}
}
};
完整的上传逻辑如下:
// 上传目录并显示进度
const uploadDirectoryWithProgress = async (localDir, remoteDir) => {
const fs = require('fs').promises;
const getLocalFiles = async (dir) => {
let files = [];
const items = await fs.readdir(dir, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
const subFiles = await getLocalFiles(fullPath);
files = files.concat(subFiles);
} else {
files.push(fullPath);
}
}
return files;
};
const localFiles = await getLocalFiles(localDir);
const totalFiles = localFiles.length;
let uploadedFiles = 0;
const uploadRecursive = async (localPath, remotePath) => {
const items = await fs.readdir(localPath, { withFileTypes: true });
for (const item of items) {
const localItemPath = path.join(localPath, item.name);
const remoteItemPath = `${remotePath}/${item.name}`;
if (item.isDirectory()) {
// 如果是目录,递归创建远程目录并上传
if (!(await sftp.exists(remoteItemPath))) {
await sftp.mkdir(remoteItemPath, true);
}
await uploadRecursive(localItemPath, remoteItemPath);
} else {
// 如果是文件,上传文件并更新进度
await sftp.fastPut(localItemPath, remoteItemPath);
uploadedFiles += 1;
displayProgressBar(uploadedFiles / totalFiles);
}
}
};
if (totalFiles === 0) {
console.log('No files to upload.');
return;
}
console.log('Starting upload...');
await uploadRecursive(localDir, remoteDir);
process.stdout.write('\n'); // 进度条后换行
console.log('Upload completed!');
};
下载也是同样的逻辑,完整版代码如下:
const SftpClient = require('ssh2-sftp-client');
const path = require('path');
const fs = require('fs');
const sftp = new SftpClient();
const localPath = path.resolve(__dirname, './dist');
const remotePath = '/Default/192.168.10.219/project/front/admin';
const remotePathDir = `${remotePath}/dist`;
const configFtp = {
host: 'qytjms.pekingulaw.cn', // 使用你的测试 host
port: 2345, // 使用你的测试端口
username: 'zhangsan', // 使用拼接后的用户名
password: '123456', // 使用你的密码
algorithms: { // 添加支持 ssh-rsa 算法 如有需要
serverHostKey: ['ssh-rsa'],
},
// debug: console.log // 输出调试信息
};
// 展示进度条
const displayProgressBar = (progress) => {
const barLength = 40; // 进度条长度
const completed = Math.round(progress * barLength);
const bar = '='.repeat(completed) + '-'.repeat(barLength - completed);
process.stdout.write(`\r[${bar}] ${(progress * 100).toFixed(2)}%`);
};
// 计算一个文件夹中文件总数
const calculateTotalSize = (dir) => {
let totalSize = 0;
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory()) {
totalSize += calculateTotalSize(filePath);
} else {
totalSize += stats.size;
}
}
return totalSize;
};
// 上传目录并显示进度
const uploadDirectoryWithProgress = async (localDir, remoteDir) => {
const fs = require('fs').promises;
const getLocalFiles = async (dir) => {
let files = [];
const items = await fs.readdir(dir, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
const subFiles = await getLocalFiles(fullPath);
files = files.concat(subFiles);
} else {
files.push(fullPath);
}
}
return files;
};
const localFiles = await getLocalFiles(localDir);
const totalFiles = localFiles.length;
let uploadedFiles = 0;
const uploadRecursive = async (localPath, remotePath) => {
const items = await fs.readdir(localPath, { withFileTypes: true });
for (const item of items) {
const localItemPath = path.join(localPath, item.name);
const remoteItemPath = `${remotePath}/${item.name}`;
if (item.isDirectory()) {
// 如果是目录,递归创建远程目录并上传
if (!(await sftp.exists(remoteItemPath))) {
await sftp.mkdir(remoteItemPath, true);
}
await uploadRecursive(localItemPath, remoteItemPath);
} else {
// 如果是文件,上传文件并更新进度
await sftp.fastPut(localItemPath, remoteItemPath);
uploadedFiles += 1;
displayProgressBar(uploadedFiles / totalFiles);
}
}
};
if (totalFiles === 0) {
console.log('No files to upload.');
return;
}
console.log('Starting upload...');
await uploadRecursive(localDir, remoteDir);
process.stdout.write('\n'); // 进度条后换行
console.log('Upload completed!');
};
// 删除目录并显示进度
const deleteDirectoryWithProgress = async (remoteDir) => {
if (!(await sftp.exists(remoteDir))) return;
// 获取所有文件的总数,用于正确计算进度
const getTotalFiles = async (dir) => {
let count = 0;
const files = await sftp.list(dir);
for (const file of files) {
// 如果是目录,递归计算目录中的文件数
if (file.type === 'd') {
count += await getTotalFiles(`${dir}/${file.name}`);
} else {
count += 1;
}
}
return count;
};
const totalFiles = await getTotalFiles(remoteDir);
let deletedFiles = 0;
const deleteRecursive = async (dir) => {
const files = await sftp.list(dir);
for (const file of files) {
const remoteFile = `${dir}/${file.name}`;
if (file.type === 'd') {
await deleteRecursive(remoteFile);
} else {
await sftp.delete(remoteFile);
deletedFiles += 1;
displayProgressBar(deletedFiles / totalFiles);
}
}
await sftp.rmdir(dir);
};
if (totalFiles === 0) {
console.log('No files to delete.');
return;
}
console.log('Starting deletion...');
await deleteRecursive(remoteDir);
process.stdout.write('\n'); // 进度条后面换行
console.log('Deletion completed!');
};
const main = async () => {
try {
await sftp.connect(configFtp);
console.log('Connected to SFTP server successfully!');
if (await sftp.exists(remotePath)) {
if (await sftp.exists(remotePathDir)) {
console.log(`Remote directory exists. Deleting: ${remotePathDir}`);
await deleteDirectoryWithProgress(remotePathDir);
}
console.log(`Creating remote directory: ${remotePathDir}`);
await sftp.mkdir(remotePathDir, true);
console.log('Uploading directory...');
await uploadDirectoryWithProgress(localPath, remotePathDir);
} else {
console.error(`Remote path does not exist: ${remotePath}`);
}
} catch (err) {
console.error('Error:', err);
} finally {
await sftp.end();
console.log('SFTP connection closed.');
}
};
main();
最终,我们的界面长这个样子:
vscode:
mac shell:
基本已经符合工程化要求,v1版本完成!
还是有几个小问题:
进度条不美观,log日志没有颜色区分,都是代码实现的,阅读体验不好,我们再来优化一下。
- 进度条 cli-progress
- log日志 chalk
这两个库都是业内应用最广泛的,也是社区最活跃的,使用起来非常简单。
// 美化进度条
const cliProgress = require('cli-progress');
const progressBar = new cliProgress.SingleBar({
format: `${chalk.red('Delete Progress')} |${chalk.cyan('{bar}')}| {percentage}% | {value}/{total} files`,
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591',
hideCursor: true,
});
注意❗️:chalk 默认安装最新版本5.x,改为了ESM方式,代码中是CommonJS方式,所以需要降级安装4.x版本。当然,如果你要使用5.x,需要自行修改 package.json 配置,改代码。本人为了保持代码写法的统一,使用的是4.x版本。
const chalk = require('chalk');
const log = {
success: (msg) => console.log(chalk.green(msg)),
error: (msg) => console.error(chalk.red(msg)),
info: (msg) => console.log(chalk.blue(msg)),
warning: (msg) => console.log(chalk.yellow(msg)),
};
使用起来非常简单,只需要在需要的地方调用log.success、log.error、log.info、log.warning即可。
上传&删除逻辑基本不变,只是修改进度条和日志的输出,最终代码如下:
const SftpClient = require('ssh2-sftp-client');
const path = require('path');
const fs = require('fs').promises;
const cliProgress = require('cli-progress');
const chalk = require('chalk');
const sftp = new SftpClient();
const localPath = path.resolve(__dirname, './dist'); // 本地路径
const remotePath = '/Default/192.168.10.219/project/front/admin'; // 远程基础路径
const remotePathDir = `${remotePath}/dist`; // 远程 dist 文件夹完整路径
const configFtp = {
host: 'qytjms.pekingulaw.cn', // 使用你的测试 host
port: 2345, // 使用你的测试端口
username: 'zhangsan', // 使用拼接后的用户名
password: '123456', // 使用你的密码
algorithms: { // 添加支持 ssh-rsa 算法 如有需要
serverHostKey: ['ssh-rsa'],
},
// debug: console.log // 输出调试信息
};
const log = {
success: (msg) => console.log(chalk.green(msg)),
error: (msg) => console.error(chalk.red(msg)),
info: (msg) => console.log(chalk.blue(msg)),
warning: (msg) => console.log(chalk.yellow(msg)),
};
// 获取所有本地文件(包括子目录)
const getAllLocalFiles = async (dir) => {
let files = [];
const items = await fs.readdir(dir, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
const subFiles = await getAllLocalFiles(fullPath);
files = files.concat(subFiles);
} else {
files.push(fullPath);
}
}
return files;
};
// 删除远程目录及其内容(整体进度条)
const deleteDirectoryWithProgress = async (remoteDir) => {
log.info(`Starting deletion of: ${remoteDir}`);
const allRemoteItems = [];
// 收集所有需要删除的项目
const collectRemoteItems = async (dir) => {
const items = await sftp.list(dir);
for (const item of items) {
const itemPath = `${dir}/${item.name}`;
if (item.type === 'd') {
await collectRemoteItems(itemPath);
}
allRemoteItems.push(itemPath);
}
allRemoteItems.push(dir); // 添加目录本身
};
await collectRemoteItems(remoteDir);
const totalItems = allRemoteItems.length;
if (totalItems === 0) {
log.warning('No items to delete.');
return;
}
// 删除并更新进度条
const progressBar = new cliProgress.SingleBar({
format: `${chalk.red('Delete Progress')} |${chalk.cyan('{bar}')}| {percentage}% | {value}/{total} files`,
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591',
hideCursor: true,
});
progressBar.start(totalItems, 0);
for (let i = 0; i < totalItems; i++) {
const itemPath = allRemoteItems[totalItems - i - 1]; // 逆序删除(先文件后目录)
try {
if (await sftp.exists(itemPath)) {
if ((await sftp.stat(itemPath)).isDirectory) {
await sftp.rmdir(itemPath);
} else {
await sftp.delete(itemPath);
}
}
} catch (err) {
log.error(`Error deleting: ${itemPath}`);
log.error(err.message);
}
progressBar.update(i + 1);
}
progressBar.stop();
log.success('Deletion completed!');
};
// 上传目录及文件(整体进度条)
const uploadDirectoryWithProgress = async (localDir, remoteDir) => {
const localFiles = await getAllLocalFiles(localDir);
const totalFiles = localFiles.length;
if (totalFiles === 0) {
log.warning('No files to upload.');
return;
}
log.info(`Starting upload of ${totalFiles} files...`);
const progressBar = new cliProgress.SingleBar({
format: `${chalk.blue('Upload Progress')} |${chalk.cyan('{bar}')}| {percentage}% | {value}/{total} files`,
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591',
hideCursor: true,
});
progressBar.start(totalFiles, 0);
for (let i = 0; i < totalFiles; i++) {
const localFilePath = localFiles[i];
const relativePath = path.relative(localDir, localFilePath);
const remoteFilePath = `${remoteDir}/${relativePath}`;
const remoteDirPath = path.dirname(remoteFilePath);
try {
if (!(await sftp.exists(remoteDirPath))) {
// 递归创建远程目录
await sftp.mkdir(remoteDirPath, true);
}
await sftp.fastPut(localFilePath, remoteFilePath);
} catch (err) {
log.error(`Error uploading: ${localFilePath}`);
log.error(err.message);
}
progressBar.update(i + 1);
}
progressBar.stop();
log.success('Upload completed!');
};
// 主函数
const main = async () => {
try {
await sftp.connect(configFtp);
log.success('Connected to SFTP server successfully!');
// 检查远程基础路径是否存在
if (await sftp.exists(remotePath)) {
// 检查 dist 文件夹是否存在
if (await sftp.exists(remotePathDir)) {
await deleteDirectoryWithProgress(remotePathDir); // 删除已有目录及内容
} else {
log.info(`Remote dist folder does not exist. Creating and uploading to: ${remotePathDir}`);
}
await uploadDirectoryWithProgress(localPath, remotePathDir); // 上传目录及文件
} else {
log.error(`Remote base path does not exist: ${remotePath}`);
}
} catch (err) {
log.error(`Error: ${err.message}`);
} finally {
await sftp.end();
log.info('SFTP connection closed.');
}
};
main();
最终界面长这个样子,大功告成!
我们的用户名密码等敏感信息直接写在了代码里,非常不安全,所以我们需要把配置文件单独抽离出来,可以自定义 sftpConfig.js 文件,也可以写在 .env 环境变量里,同时在 .gitignore 文件里忽略下,这样就不会把敏感信息提交到git仓库里了😂。
上传部署项目再和我们的打包构建命令结合起来,在 package.json 的 scripts 里加上:
"scripts": {
"serve": "vite --open --mode dev",
"serve:mock": "vite --open --mode mock",
"serve:prod": "vite --open --mode prod",
"build:dev": "vite build --mode dev",
"build:prod": "vite build --mode prod",
"preview": "vite preview",
"deploy": "pnpm run build:dev && node scripts/uploadToServer.js",
"lint": "eslint src --fix --ext .ts,.tsx,.vue,.js,.jsx"
},
这样,只需要一行命令就完成了打包和上传部署,非常方便。
四、总结
ssh2-sftp-client 是一个强大且易用的 Node.js 插件,用于简化 SFTP 文件传输操作。无论是上传、下载、删除文件,还是列出目录内容,这个插件都提供了直观且高效的 API,能够显著降低实现这些功能的复杂性。如果你的 Node.js 应用需要集成文件传输功能,不妨试试 ssh2-sftp-client。