浅析如何使用Vue + Xterm.js + SpringBoot + Websocket / Stomp + JSch 实现一个 web terminal 网页版的终端工具

  先看下具体效果:相当于就是一个网页版的 Xshell 工具,操作起来跟 Xshell 操作一样。前端主要使用 Vue + Xterm + Websocket/Stomp,后端主要使用 SpringBoot + Websocket/Stomp + JSch,下面可以看下具体实现代码,demo 代码主要是讲流程,真正在项目上的话肯定会有代码优化及修改或流程优化等。也可以按自己的理解去做,不要陷入在别人的解决思路里,最初对这方面不大了解,就是看的别人的博客,最后陷入别人的思路里乱搞了很多东西,最后只用了他的 JSch ,其他代码全部重构,就发现其实并不难,所以要有自己独立的思维很重要,这个方案也只能是 demo 实现,也并一定就是最佳的。

浅析如何使用Vue + Xterm.js + SpringBoot + Websocket / Stomp + JSch 实现一个 web terminal 网页版的终端工具

一、前端实现代码

  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 是最合适的了。

浅析如何使用Vue + Xterm.js + SpringBoot + Websocket / Stomp + JSch 实现一个 web terminal 网页版的终端工具

<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 连接终端,并将终端返回信息发送给前端展示。

上一篇:是否有一个版本的Javacc输出javascript代码?


下一篇:java代码上传本地文件到linux服务器,并put到hadoop(需要路径版,还会发一个无需路径版)