浅析如何使用Vue + Xterm.js + SpringBoot + Websocket / Stomp + JSch 实现一个 web terminal 网页版的终端工具
先看下具体效果:相当于就是一个网页版的 Xshell 工具,操作起来跟 Xshell 操作一样。前端主要使用 Vue + Xterm + Websocket/Stomp,后端主要使用 SpringBoot + Websocket/Stomp + JSch,下面可以看下具体实现代码,demo 代码主要是讲流程,真正在项目上的话肯定会有代码优化及修改或流程优化等。也可以按自己的理解去做,不要陷入在别人的解决思路里,最初对这方面不大了解,就是看的别人的博客,最后陷入别人的思路里乱搞了很多东西,最后只用了他的 JSch ,其他代码全部重构,就发现其实并不难,所以要有自己独立的思维很重要,这个方案也只能是 demo 实现,也并一定就是最佳的。
一、前端实现代码
Vue + websocket / stomp + xterm.js ,不清楚的自己查资料咯,我主要说下具体要点:
1、xterm 容器 dom,及引入 xterm.js 及 xterm 的插件 xterm-addon-fit(内含元素自适应插件)
2、websocket / stomp ,连接 - 订阅 / 取消订阅 - 发送消息等,这个比较常见,不多说了
3、要点:我们不关注用户输入什么想输入什么,只要是用户输入的每一步,我们都发送给后台,后台去发送给终端,然后拿到终端的消息返回给我们,我们去 write() 在 xterm 里即可。
说一下这里碰到的一个问题,也是一个关键点,就是之前博客我写 demo 的时候,是会想到用户输入的什么,我们前端应该先 write 显示在 xterm 上,然后去发送给后台,然后发现就是我输入一个字符会展示2个字符,因为后台会返回给我们那个字符,我在输入时 write 了一次,后台返回时又 write 一次导致重复。所以想到实际上我应该在用户输入时不write,而是直接发给后台,等后台返回我什么,我就 write 什么。如果我在用户输入时就 write,这样其实就会存在很多难以控制的问题,比如前台删除啊,左右移动删除啊,就会有很多坑,虽然在前面的博客有类似的解决,但是不是最好的方案。最好的方案就是上面的第3点。
可以看下终端返回的数据都是这种带彩色的格式的,所以我们直接拿终端返回的数据去 write 是最合适的了。
<template>
<div id="terminal" ref="terminal"></div>
</template>
<script>
import { Terminal } from "xterm"
import { FitAddon } from 'xterm-addon-fit'
import "xterm/css/xterm.css"
import Stomp from 'stompjs'
export default {
data() {
return {
term: "", // 保存terminal实例
rows: 40,
cols: 100,
stompClient: ''
}
},
mounted() {
this.initSocket()
},
methods: {
initXterm() {
let _this = this
let term = new Terminal({
rendererType: "canvas", //渲染类型
rows: _this.rows, //行数
cols: _this.cols, // 不指定行数,自动回车后光标从下一行开始
convertEol: true, //启用时,光标将设置为下一行的开头
// scrollback: 50, //终端中的回滚量
disableStdin: false, //是否应禁用输入
// cursorStyle: "underline", //光标样式
cursorBlink: true, //光标闪烁
theme: {
foreground: "#ECECEC", //字体
background: "#000000", //背景色
cursor: "help", //设置光标
lineHeight: 20
}
})
// 创建terminal实例
term.open(this.$refs["terminal"])
// 换行并输入起始符 $
term.prompt = _ => {
term.write("\r\n\x1b[33m$\x1b[0m ")
}
// term.prompt()
// canvas背景全屏
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
fitAddon.fit()
window.addEventListener("resize", resizeScreen)
function resizeScreen() {
try {
fitAddon.fit()
} catch (e) {
console.log("e", e.message)
}
}
_this.term = term
_this.runFakeTerminal()
},
runFakeTerminal() {
let term = this.term
if (term._initialized) return
// 初始化
term._initialized = true
term.writeln("Welcome to \x1b[1;32m墨天轮\x1b[0m.")
term.writeln('This is Web Terminal of Modb; Good Good Study, Day Day Up.')
term.prompt()
term.onData(key => { // 输入与粘贴的情况
this.sendShell(key)
})
},
initSocket() {
let _this = this
// 建立连接对象
let sockUrl = 'ws://127.0.0.1:8086/web-terminal'
let socket = new WebSocket(sockUrl)
// 获取STOMP子协议的客户端对象
_this.stompClient = Stomp.over(socket)
// 向服务器发起websocket连接
this.stompClient.connect({}, (res) => {
_this.initXterm()
_this.stompClient.subscribe('/topic/1024', (frame) => {
_this.writeShell(frame.body)
})
_this.sentFirst()
}, (err) => {
console.log('失败:' + err)
})
_this.stompClient.debug = null
},
sendShell (data) {
let _bar = {
operate:'command',
command: data,
userId: 1024
}
this.stompClient.send('/msg', {}, JSON.stringify(_bar))
},
writeShell(data) {
this.term.write(data)
},
// 连接建立,首次发送消息连接 ssh
sentFirst () {
let _bar = {
operate:'connect',
host: '***',
port: 22,
username: '***',
password: '***',
userId: 1024
}
this.stompClient.send('/msg', {}, JSON.stringify(_bar))
}
}
}
</script>
二、后端实现代码
1、后台开启 websocket + stomp
@Configuration
@Slf4j
@AllArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private WebSSHService webSSHService;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry ) {
//路径"/web-terminal"被注册为STOMP端点,对外暴露,客户端通过该路径接入WebSocket服务
registry.addEndpoint("web-terminal").setAllowedOrigins("*");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 用户可以订阅来自以"/topic"为前缀的消息,客户端只可以订阅这个前缀的主题
config.enableSimpleBroker("/topic");
}
@Override
public void configureWebSocketTransport(final WebSocketTransportRegistration registration) {
registration.addDecoratorFactory(new WebSocketHandlerDecoratorFactory() {
@Override
public WebSocketHandler decorate(final WebSocketHandler handler) {
return new WebSocketHandlerDecorator(handler) {
// 上线相关操作
@Override
public void afterConnectionEstablished(final WebSocketSession session) throws Exception {
// 通过创建连接的url解析出userId
String query = session.getUri().getQuery();
Integer userId = 1024;
//调用初始化连接(后面改为创建容器)
webSSHService.initConnection(userId);
//上线相关操作
super.afterConnectionEstablished(session);
}
// 离线相关操作
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
// 通过创建连接的url解析出userId
String query = session.getUri().getQuery();
Integer userId = 1024;
// 移除连接
webSSHService.close(userId);
//离线相关操作
super.afterConnectionClosed(session, closeStatus);
}
};
}
});
}
}
2、提供接口给前端用来发送消息
@Slf4j
@EmcsController
@AllArgsConstructor
@RequestMapping("/websocket")
public class WebSocketController {
private SimpMessagingTemplate template;
private WebSSHService webSSHService;
@MessageMapping("/msg")
public void sendMessage(@RequestBody WebSSHData webSSHData) {
webSSHService.recvHandle(webSSHData, template); // 处理发送消息
}
}
3、业务层 Service 用来处理业务,主要是:初始化 SSH 连接、使用 JSch 连接终端、同步发送命令给终端取得终端返回消息再发送给前台展示等
@Slf4j
@AllArgsConstructor
@EmcsService
public class WebSSHServiceImpl implements WebSSHService {
// 存放ssh连接信息的map
private static Map<Integer, Object> sshMap = new ConcurrentHashMap<>();
// 初始化 ssh 连接
@Override
public void initConnection(Integer userId) {
JSch jSch = new JSch();
SSHConnectInfo sshConnectInfo = new SSHConnectInfo();
sshConnectInfo.setJSch(jSch);
//将这个ssh连接信息放入map中
sshMap.put(userId, sshConnectInfo);
}
// 处理客户端发送的数据
@Override
public void recvHandle(WebSSHData webSSHData, SimpMessagingTemplate template) {
// 连接 ssh:connect 指令
if (webSSHData!=null && ConstantPool.WEBSSH_OPERATE_CONNECT.equals(webSSHData.getOperate())) {
//找到刚才存储的ssh连接对象
SSHConnectInfo sshConnectInfo = (SSHConnectInfo) sshMap.get(webSSHData.getUserId());
try {
connectToSSH(sshConnectInfo, webSSHData, template);
} catch (JSchException | IOException e) {
log.error("webssh连接异常");
log.error("异常信息:{}", e.getMessage());
}
}
// 输入命令(把命令输到后台终端)command 指令
else if (webSSHData!=null && ConstantPool.WEBSSH_OPERATE_COMMAND.equals(webSSHData.getOperate())) {
SSHConnectInfo sshConnectInfo = (SSHConnectInfo) sshMap.get(webSSHData.getUserId());
if (sshConnectInfo != null) {
try {
transToSSH(sshConnectInfo.getChannel(), webSSHData.getCommand());
} catch (IOException e) {
log.error("webssh连接异常");
log.error("异常信息:{}", e.getMessage());
}
}
} else {
log.error("不支持的操作");
}
}
// 使用jsch连接终端
private void connectToSSH(SSHConnectInfo sshConnectInfo, WebSSHData webSSHData, SimpMessagingTemplate template) throws JSchException, IOException {
//获取jsch的会话
Session session = sshConnectInfo.getJSch().getSession(webSSHData.getUsername(), webSSHData.getHost(), webSSHData.getPort());
Properties config = new Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
//设置密码
session.setPassword(webSSHData.getPassword());
//连接 超时时间30s
session.connect(30000);
//开启shell通道
Channel channel = session.openChannel("shell");
//通道连接 超时时间3s
channel.connect(3000);
//设置channel
sshConnectInfo.setChannel(channel);
//转发消息给终端
transToSSH(channel, "\r");
//读取终端返回的信息流
InputStream inputStream = channel.getInputStream();
try {
//循环读取
byte[] buffer = new byte[1024];
int i = 0;
//如果没有数据来,线程会一直阻塞在这个地方等待数据。
while ((i = inputStream.read(buffer)) != -1) {
template.convertAndSend("/topic/" + webSSHData.getUserId(), new String(Arrays.copyOfRange(buffer, 0, i)));
}
} finally {
//断开连接后关闭会话
session.disconnect();
channel.disconnect();
if (inputStream != null) {
inputStream.close();
}
}
}
// 将消息转发到终端
private void transToSSH(Channel channel, String command) throws IOException {
if (channel != null) {
OutputStream outputStream = channel.getOutputStream();
outputStream.write(command.getBytes());
outputStream.flush();
}
}
// 关闭连接
@Override
public void close(Integer userId) {
SSHConnectInfo sshConnectInfo = (SSHConnectInfo) sshMap.get(userId);
if (sshConnectInfo != null) {
//断开连接
if (sshConnectInfo.getChannel() != null) {
sshConnectInfo.getChannel().disconnect();
}
//map中移除
sshMap.remove(userId);
}
}
}
如上就是主要 demo 流程代码,其实还比较简单,总结一下就是:
(1)前端通过 websocket 与后端建立连接,在 websocket 上可以包一层 stomp;
(2)在 websocket 用户连接的同时,为该用户创建 SSH 连接
(3)前后端连接成功之后,前端就初始化 Xterm,订阅频道,同时携带服务器信息发送消息给后端请求连接终端服务器(JSch指令connect);JSch连接终端成功之后拿取终端返回的信息,后端将终端返回的信息发送给前端,前端 write 在 xterm 上;
(4)用户输入的每个操作,前端都发送给后台(JSch指令command),后台通过 JSch 发送给终端,拿取终端返回的信息,再返回给前端用于 write 在 Xterm 上即可。
websocket连接成功 —— 后台建立 SSH 连接 —— 前端初始化 Xterm —— 前端订阅频道 —— 前端发消息请求连接终端 —— 后台收到 connect 指令则通过 JSch 连接终端,并将终端返回信息发送给前端展示 —— 前端发送用户的操作指令给后台 —— 后台转发 JSch 连接终端,并将终端返回信息发送给前端展示。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
2018-07-17 纯css解决div隐藏浏览器原生滚动条,但保留鼠标滚动效果的问题
2017-07-17 浅析webpack之shimming垫片的理解及常见场景和webpack.ProvidePlugin插件的使用
2017-07-17 代码git提交规范CommitLint使用
2017-07-17 浏览器工作原理:浅析浏览器渲染进程 - HTML、CSS和JavaScript是如何变成页面的?(下)