大家好,我是公众号「线下聚会游戏」作者HullQin,开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏。
背景
在专栏《Go WebSocket》里,有一些前置文章:
第一篇文章:《为什么我选用Go重构Python版本的WebSocket服务?》,介绍了我的目标。
第二篇文章:《你的第一个Go WebSocket服务: echo server》,介绍了一下怎么写一个WebSocket server。
第三篇文章:《单房间的聊天室》,介绍了如何实现一个单房间的聊天室。
第四篇文章:《多房间的聊天室(一)思考篇》,介绍了实现一个多房间的聊天室的思路。
第五篇文章:《多房间的聊天室(二)代码实现》,介绍了实现一个多房间的聊天室的代码。
第六篇文章:《多房间的聊天室(三)自动清理无人房间》,介绍了如何清理无人的房间,避免内存无限增长的问题。
第七篇文章:《多房间的聊天室(三)自动清理无人房间》,介绍了如何避免并发导致的资源竞争的问题,是通过悲观锁解决的。
温馨提示:阅读本文不需要阅读前面的文章。但最好先读完前三篇。
本文介绍了一个gorilla/websocket官方提供的简易版的web shell案例。
代码
见这里: https://github.com/gorilla/websocket/blob/master/examples/command/main.go
体验
go run main.go sh
然后浏览器打开 127.0.0.1:8080,就可以输入ls
、pwd
等命令体验了。
但是这是个简易的 Web Shell,所以不支持vim这种命令。只能处理简单的stdin和stdout。
另外,它有一个参数,刚才我们传入的是sh
,你也可以传入其它可执行命令,例如echo
,会开启echo的交互命令。执行go run main.go echo
后,在浏览器内,你输入什么,它返回什么。
从main函数开始
阅读一段go代码,应该从外到里,一层一层拨开她的衣。
var (
addr = flag.String("addr", "127.0.0.1:8080", "http service address")
cmdPath string
)
func main() {
flag.Parse()
if len(flag.Args()) < 1 {
log.Fatal("must specify at least one argument")
}
var err error
cmdPath, err = exec.LookPath(flag.Args()[0])
if err != nil {
log.Fatal(err)
}
http.HandleFunc("/", serveHome)
http.HandleFunc("/ws", serveWs)
log.Fatal(http.ListenAndServe(*addr, nil))
}
flag.Parse()
是在处理参数,这里要求必须有1个参数,后续会执行这个参数对应的命令,就可以在Web中交互了。
关于exec.LookPath(flag.Args()[0])
:这是在环境变量中寻找PATH,返回一个字符串(可能是绝对路径或相对路径)。
随后启动了http服务(用serveHome
处理),和websocket服务(用serveWs处理)。前者是展示html,后者处理websocket。
阅读serveWs
建立ws连接
ws, err := upgrader.Upgrade(w, r, nil)
defer ws.Close()
上面是建立ws连接,以前聊过,不多说了。
创建用于标准输出的Pipe
outr, outw, err := os.Pipe()
if err != nil {
internalError(ws, "stdout:", err)
return
}
defer outr.Close()
defer outw.Close()
Pipe returns a connected pair of Files; reads from r return bytes written to w. It returns the files and an error, if any.
上面是新建管道os.Pipe,之后用于连接标准输出。
创建用于标准输入的Pipe
inr, inw, err := os.Pipe()
if err != nil {
internalError(ws, "stdin:", err)
return
}
defer inr.Close()
defer inw.Close()
上面是新建管道os.Pipe,之后用于连接标准输出。
让操作系统执行PATH对应的命令(启动了新的进程)
刚刚我们LookPath找到了具体要执行的命令,现在让操作系统执行它,通过os.StartProcess
:
proc, err := os.StartProcess(cmdPath, flag.Args(), &os.ProcAttr{
Files: []*os.File{inr, outw, outw},
})
if err != nil {
internalError(ws, "start:", err)
return
}
inr.Close()
outw.Close()
StartProcess会启动一个进程,flag.Args()作为它的参数。
为了在Go这个进程中跟另一个进程交互,需要通过Pipe连接,就是我们刚才定义的2个。程序都有标准输入、标准输出、异常输出,所以定义了os.ProcAttr
,这里我们把异常输出也输出到了标准输出了。
启动其它goroutine,处理输入输出
stdoutDone := make(chan struct{})
go pumpStdout(ws, outr, stdoutDone)
go ping(ws, stdoutDone)
pumpStdin(ws, inw)
pumpStdout处理进程的输出;pumpStdin处理进程的输入。ping只是为了跟客户端保持持久的连接。
stdoutDone
是进程结束的标志,结束后,ws连接也要断开(毕竟连着也没法交互,没意义了)。
先阅读简单的ping
func ping(ws *websocket.Conn, done chan struct{}) {
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := ws.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(writeWait)); err != nil {
log.Println("ping:", err)
}
case <-done:
return
}
}
}
是个死循环,每隔一段时间(pingPeriod),都会主动个PingMessage,保持连接。浏览器收到后会自动回复Pong消息。通过这种方式,双方都知道彼此还连着。
当然,如果done了,进程结束,就可以停止ping了。相反也是一样,ws断开连接时,进程也可以结束了。
阅读pumpStdin
注意,pumpStdin不是在serveWs用go开启的goroutine。所以到这里时,其实serveWs就阻塞在pumpStdin里的死循环了。
func pumpStdin(ws *websocket.Conn, w io.Writer) {
defer ws.Close()
ws.SetReadLimit(maxMessageSize)
ws.SetReadDeadline(time.Now().Add(pongWait))
ws.SetPongHandler(func(string) error { ws.SetReadDeadline(time.Now().Add(pongWait)); return nil })
for {
_, message, err := ws.ReadMessage()
if err != nil {
break
}
message = append(message, '\n')
if _, err := w.Write(message); err != nil {
break
}
}
}
主要就是读取ws
消息,然后把消息写入w
(即inw
这个Pipe),之后,上面说的新启动的进程会收到这个消息。
阅读pumpStdout
func pumpStdout(ws *websocket.Conn, r io.Reader, done chan struct{}) {
s := bufio.NewScanner(r)
for s.Scan() {
ws.SetWriteDeadline(time.Now().Add(writeWait))
if err := ws.WriteMessage(websocket.TextMessage, s.Bytes()); err != nil {
ws.Close()
break
}
}
if s.Err() != nil {
log.Println("scan:", s.Err())
}
close(done)
ws.SetWriteDeadline(time.Now().Add(writeWait))
ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
time.Sleep(closeGracePeriod)
ws.Close()
}
新启动的进程有标准输出或异常输出时,会发送到Pipe,我们代码中通过outr
可获取到输出,即本函数的参数r
。
这是个死循环,不断读取s.Scan()
进程输出,然后通过ws发给客户端。
直到ws断开,就结束了进程close(donw)
。
写在最后
我是HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者HullQin授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费没广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我 HullQin 噢~我有空了会分享做游戏的相关技术。