先看下具体效果:相当于就是一个网页版的 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 连接终端,并将终端返回信息发送给前端展示。
浅析如何使用Vue + Xterm.js + SpringBoot + Websocket / Stomp + JSch 实现一个 web terminal 网页版的终端工具
原文:https://www.cnblogs.com/goloving/p/15025262.html