先看下具体效果:相当于就是一个网页版的 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 连接终端,并将终端返回信息发送给前端展示。