小白的实时通信之路

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/phantom_111/article/details/85214231

1. 定义

1.1 背景介绍

通信: 客户端和服务端的一次交互过程。可以简述为如下步骤:

  • 客户端发出请求

  • 服务端接收,处理,返回结果

  • 客户端接收结果

实时通信:所谓「实时通信」就是要求客户端能够收到服务端实时更新的结果,其实简单理解就是在通信这个词上加了「实时的」这个形容词,所以本质上通信的双方还是客户端和服务端。举个生活中实时通信的例子,约会中,为了实时获取到对方的位置,可能采用以下的方式,此处以 A 代表约会发起方(客户端),B 代表约会接受方 (服务端)(纯属举例):

  • A 每隔 3 分钟给 B 播一通电话,确认 B 的位置,然后挂断电话——polling

  • A 给 B 拨通电话后,保持着电话连接状态,等 B 的位置向目的移动 5 公里以后给 A 回复,然后挂断电话——long polling

  • A 给 B 拨通电话后,始终保持着电话连接状态,A 每隔 3 分钟给问一次 B 的位置,B 收到请求前进 5 公里后给就给 A 一个回复——long connection

  • A 给 B 拨通电话后,始终保持着电话连接状态,同时双方每前进 5 公斤就给对方一个回复——websocket

1.2 概念介绍

polling: 客户端每隔一段时间向服务端发送请求来获取数据。

long polling: 长轮询是在建立连接以后保持,等待数据更新服务端推送更新数据再关闭。

  • 长轮询必须使用长链接
  • 长轮询是一种服务端推送数据的技术
  • 长轮询实现服务端推数据的方式是 hold 住一个请求(建议设置超时时间),直到有数据更新则给客户端响应。再次强调,长轮询 hold 住的应该是一个 request 请求,不是 hold 住一个连接

long connection: 在连接建立以后,客户端和服务端保持该连接直至有一方主动关闭连接,实质上保持该通道,实现多路复用。

  • 长连接描述的是 TCP 连接,允许复用该 TCP 连接上发起多次请求,使用长链接后并不是一次请求建立一个 TCP连接

websocket: 一种在单个 TCP 连接上进行全双工通信的协议,允许双向数据传输,使得客户端和服务端之间的数据交换变得更加简单。

  • http 协议是无状态的,客户端发起一次 request, 服务端返回一次 response,这属于被动式的「单工」通信,但 websocket 是全双工的,既允许客户端向服务端发起请求,也允许服务端向客户端推送数据
  • websocket 的出现是为了在应用层实现类似传输层的 TCP 协议

1.3 优缺点对比

优点 缺点 适用场景
polling 编写简单 频繁建立、关闭连接,效率低下,浪费带宽和服务器资源
long polling 无更新的情况不会频繁请求,减少连接建立的资源消耗 后端需单独维护挂起连接,挂起的连接会消耗后端资源
long connection 多个请求复用同一连接,可用于实现消息实时推送 后端需单独维护长链接,消耗后端资源 直播,流媒体
web socket 真正意义上的长链接且全双工 改造略复杂,前后端均需做支持
  • 从实现复杂程度上考虑, polling > long polling > long connection > websocket
  • 从性能方面考虑, websocket > long connection > long polling > polling

2. 实现

为了防止出现「懂了这么多理论,还是写不出代码」的情况出现,下面会开始介绍四种例子的实现,为了避免使用 hello world,笔者特意抽取日剧《非自然死亡》里的经典台词做为传输内容。

2.1 polling 和 long polling

上述两种方式的实现代码非常简单,笔者这里不做介绍,但会对上面的理论知识做验证。

验证:每次的请求是否会导致底层连接频繁创建

  • 使用 net/http 创建 client,每间隔一秒轮询一次服务端数据,数据截图:

    • 通信内容截图:
      在这里插入图片描述

    注:通信内容截图没什么意义,单纯展示下《非自然死亡》经典台词。

    • tcpdump 抓包截图:
      在这里插入图片描述

    注:tcpdump 抓包截图是在通信过程中,对服务端 9162 端口抓包截图,可以看出并没有出现理论知识中的频繁建立连接的过程。由于笔者用的 DefaultClient 而它底层用了 DefaultTransport,所以原因在这里:

    DefaultTransport is the default implementation of Transport and is used by DefaultClient. It establishes network connections as needed and caches them for reuse by subsequent calls. It uses HTTP proxies as directed by the $HTTP_PROXY and $NO_PROXY (or $http_proxy and $no_proxy) environment variables.

    var DefaultTransport RoundTripper = &Transport{
            Proxy: ProxyFromEnvironment,
            DialContext: (&net.Dialer{
                    Timeout:   30 * time.Second,
                    KeepAlive: 30 * time.Second,
                    DualStack: true,
            }).DialContext,
            MaxIdleConns:          100,
            IdleConnTimeout:       90 * time.Second,
            TLSHandshakeTimeout:   10 * time.Second,
            ExpectContinueTimeout: 1 * time.Second,
    }
    
  • 改用浏览器手动调用轮询接口,tcpdump 截图如下:
    在这里插入图片描述

    注:确实浏览器手动调用发生了频繁的连接建立与销毁

结论: 如果使用 net/http 库创建客户端轮询获取服务端更新,并不会引起连接频繁创建和销毁,具体底层连接的保持参数可以调整。

2.2 long connection

长链接的代码实现,主要有两个需要注意的地方,一个服务端在发送数据后需要刷新缓存,另一个是客户端如何解析服务端发送的数据。

服务端刷新缓存:

type flushWriter struct {
	f http.Flusher
	w io.Writer
}

func (fw flushWriter) Write(p []byte) (n int, err error) {
	n, err = fw.w.Write(p)
	if fw.f != nil {
		fw.f.Flush()
	}
	return
}
func longConnection(w http.ResponseWriter, r *http.Request) {
	fw := flushWriter{w: w}
	if f, ok := w.(http.Flusher); ok {
		fw.f = f
	}
	var (
		cqtSize = len(classicQuoteTable) //[]string 类型,存储经典语录内容
		c       = make(chan classicQuote, 1)
		index   uint8
	)
	go func(c chan classicQuote) {
		for {
			time.Sleep(1 * time.Second)
			c <- classicQuote{
				Content: classicQuoteTable[int(index)%cqtSize],
			}
			index++
		}

	}(c)
	for {
		select {
		case r := <-c:
			br, err := json.Marshal(r)
			if err != nil {
				fmt.Println("longConnection marshal failed", err)
				break
			}
			fw.Write(br)
		}
	}
}

注:刷新缓存Golang 实现不带缓冲的 http.ResponseWritter介绍的比较详细,笔者此处不做过多介绍。

客户端读取数据

	client := &http.Client{
		Transport: &http.Transport{
			DialContext: (&net.Dialer{
				Timeout:   30 * time.Second,
				KeepAlive: 30 * time.Second,
			}).DialContext,
		},
	}

	for {
		rqst, err := http.NewRequest("GET", "http://127.0.0.1:9162/long_connection", nil)
		if err != nil {
			fmt.Println(err)
			return
		}
		resp, err := client.Do(rqst)
		if err != nil {
			fmt.Println(err)
			return
		}
		defer resp.Body.Close()
        //流式从 http response body 读取服务端返回数据
		decoder := json.NewDecoder(resp.Body)
		cq := new(classicQuote)
		err = decoder.Decode(cq)   
		for err == nil {
			fmt.Printf("%v\n", cq)
			cq = new(classicQuote)
			err = decoder.Decode(cq)
		}
		fmt.Println("err", err)
	}

2.3 websocket

已经存在很多优秀的 websocket 库,所以实现起来不复杂。golang.org/x/netgithub.com/gorilla 这两个库对比下来,后者的优势更明显,实现更多 RFC 6455 规范支持的特性,此处展示笔者使用后者库的例子。

服务端

收到客户端查询经典语录的消息后,随机选择一条返回给客户端。

type rqstMsg struct {
	Action string `json:"Query"`
}

var upgrader = websocket.Upgrader{}

func websocketHandle(w http.ResponseWriter, r *http.Request) {
	c, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		fmt.Printf("upgrader web socket failed, err %v\n", err)
		return
	}
	defer c.Close()
	var (
		index      int
		transferCh = make(chan struct{}, 1)
	)
	go func() {
		for {
			var r rqstMsg
			err := c.ReadJSON(&r)
			if err != nil {
				fmt.Printf("read err %v\n", err)
				return
			}
			fmt.Printf("recv: %s\n", r)
			transferCh <- struct{}{}
		}
	}()
	for {
		select {
		case <-transferCh:
			index++
			err = c.WriteJSON(classicQuoteTable[index%len(classicQuoteTable)])
			if err != nil {
				fmt.Printf("write err %v\n", err)
				break
			}
		}
	}
}

客户端

间隔 30s 轮询一次服务端经典语录的内容。

type rqstMsg struct {
	Action string `json:"Query"` //websocket 连接上传送的消息格式
}

func main() {
	u := url.URL{
		Scheme: "ws",
		Host:   "127.0.0.1:9162",
		Path:   "web_socket",
	}
	c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) //建立 websocket 连接
	if err != nil {
		fmt.Printf("dail websocket server error %v\n", err)
		return
	}
	defer c.Close()
	var (
		interrupt = make(chan os.Signal, 1)
		done      = make(chan struct{})
	)
	go func() {
		defer close(done)
		for {
			var respMsg string
			err := c.ReadJSON(&respMsg)
			if err != nil {
				fmt.Printf("read message error %v", err)
				return
			}
			fmt.Printf("recv message %s\n", respMsg)
		}
	}()
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()
	c.SetWriteDeadline(time.Now().Add(60 * time.Second)) //设置读取超时时间,超时关闭连接
	for {
		select {
		case <-done:
			return
		case <-ticker.C:
			err := c.WriteJSON(rqstMsg{Action: "test"})
			if err != nil {
				log.Printf("write err: %v", err)
				return
			}
		case <-interrupt:
			log.Println("interrupt")
			err := c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
			if err != nil {
				log.Println("write close", err)
				return
			}
			select {
			case <-done:
			case <-time.After(time.Second):
			}
			return
		}
	}
}

注:笔者认为使用 websocket 协议的关键是要准确定义在 websocket 连接上传送的消息格式。

3. 思考

至此,这篇文章已全部总结完成,以上均为笔者个人理解,如有错误欢迎指出。

4. 参考资料

猜你喜欢

转载自blog.csdn.net/phantom_111/article/details/85214231
今日推荐