• 浅析如何使用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("
    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, " "); //读取终端返回的信息流 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 连接终端,并将终端返回信息发送给前端展示。

  • 相关阅读:
    c#中常用的一些异常类小结希望大家留言补充
    【转】ASP.NET学习步骤
    【转】Android是什么?
    【转】.NET各大网站编程技术网址
    毕业设计日志(一)面向对象编程基础
    实习日志(1)
    多边形
    颜色选择器
    java多线程小练习
    方块移动
  • 原文地址:https://www.cnblogs.com/goloving/p/15025262.html
Copyright © 2020-2023  润新知