SshService 输出结构优化与异常捕获
上节完成了定时备份任务的实现,但在测试过程中发现 SshService.exec() 方法存在两个需要优化的问题:
- 错误处理不当:当 SSH 命令执行失败时,直接
reject会导致错误进入 NestJS 全局异常过滤器并记录 error 级别日志。但在实际业务中,命令执行失败(如语法错误)的结果也需要返回给前端展示,不应该作为程序异常处理。 - 流数据拼接缺失:
on('data')是一个流式事件,可能触发多次。需要将所有数据片段拼接为完整字符串后一次性输出。
问题分析
优化前的 SshService.exec() 方法结构如下:
// Before optimization
exec(command: string): Promise<string> {
return new Promise((resolve, reject) => {
this.client.exec(command, (err, stream) => {
if (err) reject(err);
stream.on('close', (code, signal) => {
resolve(/* incomplete */);
}).on('data', (data) => {
// Data received but not accumulated
}).stderr.on('data', (data) => {
reject(data); // Should not reject for command errors
});
});
});
}
typescript
核心问题: 命令执行返回的非零退出码(如命令拼写错误)不应视为程序异常。只有 SSH 连接本身的错误才应该 reject。
优化方案:结构化输出
将 exec 方法的返回值从简单字符串改为结构化对象,统一包含成功和失败的信息:
// ssh.service.ts
import { Injectable } from '@nestjs/common';
import { Client } from 'ssh2';
export interface SshExecResult {
code: number | null;
signal: string | null;
output: string;
}
@Injectable()
export class SshService {
private client: Client;
async exec(
command: string,
onData?: (data: string) => void,
): Promise<SshExecResult> {
return new Promise((resolve, reject) => {
let output = '';
this.client.exec(command, (err, stream) => {
if (err) {
reject(err);
return;
}
stream
.on('close', (code, signal) => {
resolve({ code, signal, output });
})
.on('data', (data) => {
output += data.toString();
onData?.(data.toString());
})
.stderr.on('data', (data) => {
// Append stderr with prefix marker
output += `error: ${data.toString()}`;
});
});
});
}
}
typescript
设计要点
1. 统一的输出结构
interface SshExecResult {
code: number | null; // Exit code: 0 = success, non-zero = command error
signal: string | null; // Termination signal (usually null)
output: string; // Combined stdout + stderr output
}
typescript
- 成功命令:
{ code: 0, signal: null, output: "Docker version 24.0.7\n" } - 失败命令:
{ code: 2, signal: null, output: "error: command not found\n" }
2. 不 reject 命令错误
// Correct: command errors go to output, not reject
stderr.on('data', (data) => {
output += `error: ${data.toString()}`;
});
typescript
只有 SSH 连接级别的错误(如网络断开、认证失败)才会触发 reject,进入 NestJS 的异常过滤器并记录 error 日志。
3. 可选的 onData 回调
通过 onData 回调参数,调用方可以实时接收流数据,用于日志展示等场景:
await this.sshService.exec('tail -f /var/log/syslog', (data) => {
console.log('Real-time output:', data);
});
typescript
4. 流数据拼接
let output = '';
stream.on('data', (data) => {
output += data.toString();
});
stream.stderr.on('data', (data) => {
output += `error: ${data.toString()}`;
});
typescript
所有 stdout 和 stderr 数据都追加到同一个 output 字符串中,在 close 事件时统一返回。
测试验证
在 Controller 中添加测试路由:
@Post('ssh-test')
async sshTest(@Body() body: { command: string }) {
return await this.sshService.exec(body.command);
}
typescript
测试用例 1 -- 成功命令:
# Request
POST /ssh-test
{ "command": "ls -la" }
# Response
{
"code": 0,
"signal": null,
"output": "total 48\ndrwxr-xr-x 6 root root 4096 ...\n..."
}
bash
测试用例 2 -- 错误命令:
# Request
POST /ssh-test
{ "command": "invalid_command_xyz" }
# Response
{
"code": 2,
"signal": null,
"output": "error: bash: invalid_command_xyz: command not found\n"
}
bash
无论命令成功还是失败,接口都返回正常响应,前端可以根据 code 字段判断执行状态。
对定时任务的影响
优化后,TaskService 中的备份任务不再需要额外的 try/catch 来处理命令执行错误:
// Cron task - simplified error handling
const res = await this.sshService.exec(cmd);
if (res.code !== 0) {
// Command failed, but we have the error message in res.output
this.logger.warn(`Backup command failed: ${res.output}`);
}
typescript
只有在 sshService.exec() 本身抛出异常(SSH 连接断开等)时才需要 catch,这类错误属于真正的程序异常。
本节总结
- 将
exec方法的返回值从字符串改为{ code, signal, output }结构化对象 - 命令执行失败(非零退出码)不再触发
reject,而是作为正常响应返回 - 仅 SSH 连接级别的错误才触发
reject,进入全局异常过滤器 - 添加
onData回调支持流式数据实时获取 - 所有 stdout/stderr 数据拼接后统一输出,避免流式数据丢失
↑