大家好,我是公众号「线下聚会游戏」作者,开发了​​《联机桌游合集》​​​,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏。其中的核心技术就是WebSocket,我会分享如何用Go实现WebSocket服务,文章写在专栏​​《Go WebSocket》​​里,关注专栏跟我一起学习吧!

背景

在专栏​​《Go WebSocket》​​里,有一些前置文章:

第一篇文章:​​《为什么我选用Go重构Python版本的WebSocket服务?》​​,介绍了我的目标。

第二篇文章:​​《你的第一个Go WebSocket服务: echo server》​​,介绍了一下怎么写一个WebSocket server。

第三篇文章:​​《你的第二个Go WebSocket服务: 聊天室》​​,介绍了如何实现一个单房间的聊天室。

第四篇文章:​​《你的第三个Go WebSocket服务: 多房间的聊天室(上:思考篇)》​​,介绍了实现一个多房间的聊天室的思路。

第五篇文章:​​《你的第三个Go WebSocket服务: 多房间的聊天室(下:实践篇)》​​,介绍了实现一个多房间的聊天室的代码。

第六篇文章:​​《你的第三个Go WebSocket服务: 多房间的聊天室(自动清理无人房间)》​​,介绍了如何清理无人的房间,避免内存无限增长的问题。

第七篇文章:​​《你的第三个Go WebSocket服务: 多房间的聊天室(黑天鹅事件)》​​,介绍了如何避免并发导致的资源竞争的问题,是通过悲观锁解决的。

温馨提示:阅读本文不需要阅读前面的文章。但最好先读完前三篇。

本文介绍了一个gorilla/websocket官方提供的简易版的web shell案例。

代码

见这里: ​​github.com/gorilla/web…​

体验

go run main.go sh

然后浏览器打开 ​​127.0.0.1:8080​​​,就可以输入​​ls​​​、​​pwd​​等命令体验了。

[Go WebSocket] 基于Go WebSocket手写一个简易版的远程web shell_后端

但是这是个简易的 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连接,以前聊过,不多说了。

创建用于标准输出的Pipe

outr, outw, err := os.Pipe()
if err != nil {
internalError(ws, "stdout:", err)
return
}
defer outr.Close()
defer

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

上面是新建管道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

StartProcess会启动一个进程,flag.Args()作为它的参数。

为了在Go这个进程中跟另一个进程交互,需要通过Pipe连接,就是我们刚才定义的2个。程序都有标准输入、标准输出、异常输出,所以定义了​​os.ProcAttr​​,这里我们把异常输出也输出到了标准输出了。

启动其它goroutine,处理输入输出

stdoutDone := make(chan struct{})
go pumpStdout(ws, outr, stdoutDone)
go

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,独立开发了​​《联机桌游合集》​​​,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费无广告。还独立开发了​​《合成大西瓜重制版》​​​。还开发了​​《Dice Crush》​​​参加Game Jam 2022。喜欢可以关注我噢~我有空了会分享做游戏的相关技术,会在这2个专栏里分享:​​《教你做小游戏》​​​、​​《极致用户体验》​​。

本文正在参加​​技术专题18期-聊聊Go语言框架​​。