🌋 WebIDE 的开发记录其四(命令行终端)
!本篇文章过于久远,其中观点和内容可能已经不准确,请见谅!~
程序员对电脑的控制大部分还是需要终端的帮助,运行服务、执行代码、调试等等操作都可以在终端中完成,甚至编辑功能也可以在终端中实现,运行环境的管理、文件、程序等能在终端中操作。一般意义上,IDE 和 编辑器的最大区别也是有终端功能的支持。所以日常开发终端是必不可少的一个工具,而在 web 中也已经有很成熟的工具 Xterm.js。
TTY、terminal、Shell、pty、bash、ssh 都是什么?
- tty unix系统中表示为可以输入输出的设备,现在说的比较多的是虚拟终端
- terminal 表示文本的输入输出环境,一个输入和显示设备的概念,表示我们能在终端中输入,系统能在终端中输出,很多时候也就是说 tty
- Shell 是一个命令行解释器,是一个软件,根据我们在 terminal 的输入来运行程序并将输出交给 terminal
- pty 是 虚拟终端 的概念,表示通过模拟一个输入输出的逻辑提供类似 tty 的工作机制
- bash 是 Shell 程序的一种,除此之外还有 sh、zsh 等,负责解释执行终端的字符,并调用程序
- ssh 是远程传输协议,允许终端通过这个协议连接服务端,然后 sshd 程序接收数据交给 tty / pty
- 还有 ptmx、pts、tmux 之类的概念和这无关就不深入了
比如 node-pty 通过对进程的挂载,提供一个输入输出来模拟终端的接入。
用 websocket 来创建一个连接服务端和客户端的通道,将客户端的输入交给 node-pty ,将 node-pty 的输出交给客户端
Xterm.js 在客户端实现一个类似 终端的界面,然后可以接收服务端的输出作为客户端输出显示,相应用户的输入能够传给服务端作为输入。
最终也就完成了整个终端功能的模拟。
Xterm 的集成没花多长时间,很简单,不过技术选型演进,加上小功能优化还是花了挺多精力。
第一版是通过 node-pty 现成的 demo 直接运行,效果很不错。
server 端的部分:
const os = require('os');const pty = require('node-pty');const app = require('express')();const http = require('http').createServer(app);const io = require('socket.io')(http);// 通过挂载 process 来模拟终端程序const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';const ptyProcess = pty.spawn(shell, [], {name: 'xterm-demo',cols: 80,rows: 30,cwd: process.env.HOME,env: process.env});// 通过 socket.io 来输入输出io.on('connection', (socket) => {socket.on('_term_write_in', (msg) => {ptyProcess.write(`${msg}\r`);});socket.on('_term_resize', (msg) => {ptyProcess.resize(msg.w, msg.h);});ptyProcess.on('data', function(data) {socket.emit('_term_write_out', data);});});// 开启 serverapp.get('/', (req, res) => {res.sendFile(__dirname + '/index.html');});http.listen(3000, () => {console.log('listening on *:3000');});
client 的部分:
import io from 'socket.io-client';// 开启 ws client,socket.io 不需要关心重连和复杂数据类型等处理const socket = io('http://localhost');// 开启终端绑定到视图const term = new Terminal();term.open(document.getElementById('terminal'));// 连接终端和 ws 数据通道socket.on('connect', () => {socket.on('_term_write_out', (msg) => {term.write(msg)});term.onData((data) => {socket.emit('data', data)})});
node-pty 是在机器本身执行的,和 server 集成在一起,不是特别干净,后来加入 docker 技术栈的时候就没办法使用了,需要将终端服务从 docker 中引入,好在 docker 本身可以通过
exec
支持这个功能:服务端:
const container: Docker.Container = docker.getContainer(config.containerId);// 判断 docker 容器状态是否运行const ContainerInfo = await container.inspect();if (!ContainerInfo.State.Running) {ctx.socket.emit("error", ContainerInfo.State.Status);}// 定义终端配置const cmd = {AttachStdout: true,AttachStderr: true,AttachStdin: true,Tty: true,WorkingDir: workdir || "/workspace",Cmd: ["zsh"]};// 在容器上运行终端container.exec(cmd, (err, exec) => {// 监听容器container.wait((_, __) => {ctx.socket.emit("end", "ended");});if (err) {return;}// 执行配置const options = {Tty: true,Detach: false,stream: true,stdin: true,stdout: true,stderr: true,hijack: true // fix vim};// 启动一个进程来运行,拿到 stream 数据流来和客户端交互exec.start(options, (_: any, stream: NodeJS.WritableStream) => {// 初始化输入输出的尺寸const dimensions = {h: parseInt(ctx.socket.handshake.query.rows),w: parseInt(ctx.socket.handshake.query.cols)};if (dimensions.h != 0 && dimensions.w != 0) {exec.resize(dimensions, () => {});}// 监听客户端的输入,发送到 exec 的 steamctx.socket.on("data", data => stream.write(data));// 监听客户端的输入,发送到 exec 的 steamctx.socket.on("resize", dimensions =>exec.resize(dimensions, () => {}));ctx.socket.on("exit", _ => exiter());// 监听 exec 的输出,发送到客户端stream.on("data", chunk => ctx.socket.emit("data", chunk.toString()));// 监听 exec 的错误stream.on("error", data => ctx.socket.emit("error", data));// 监听 exec 的结束stream.on("end", data => ctx.socket.emit("exit", data));});});
客户端:
// 连接服务端this.socket = io('ws://127.0.0.1:7001/term', connectOpts);// 数据监听this.socket.on('connect', () => {this.updateSize();this.setState({connectStatus: 1});});this.socket.on('connected', () => {this.updateSize();this.triggerListeners('socket-connected');});this.socket.on('disconnect', () => {this.setState({ connectStatus: 2 });this.triggerListeners('socket-disconnect');})this.socket.on('exit', () => {this.disconnect();this.triggerListeners('socket-exit');})this.socket.on('error', () => {})// 服务端输出到 xtermthis.socket.on('data', (data: any) => {this.ondownStream();this.xterm.write(data)this.triggerListeners('socket-data', { data });})// 数据和状态监听this.xterm.onTitleChange((title) => {this.setState({ title })});this.xterm.onData((data) => {this.updateSize();this.onupStream();this.socket.emit('data', data)this.triggerListeners('xterm-data', { data });})this.xterm.onResize(() => {this.setState({dims: `${this.xterm.cols}×${this.xterm.rows}`})})this.xterm.onCursorMove(() => {this.setState({cursor: `${this.xterm.buffer.cursorX}×${this.xterm.buffer.cursorY}`})})this.xterm.focus();this.updateSize();
加上一些边界和视图的样式,就能实现最开始视频演示的效果了~
在集成 docker 的时候,出现一个问题就是客户端刷新多次之后,服务端出现很多连接进程没办法自动中断。简单搜索之后发现因为 Terminal 在 Container 里启动了 zsh,而 zsh 可以随意执行命令启动进程,僵尸进程问题很难避免。
不过可以使用 phusion/baseimage 镜像来解决这个问题,可以查看 官方文档。
感谢您的阅读,本文由 Ubug 版权所有。如若转载,请注明出处:Ubug(https://ubug.io/blog/workpad-part-4)