先看下具體效果:相當於就是一個網頁版的 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 連接終端,並將終端返回信息發送給前端展示。
