Web即时通讯的几种方式

前言

在web开发中,我们经常会遇到这样的场景:服务端某个操作完成,或者发生了某种变化时,需要实时通知到客户端/浏览器,我们就称其为即时通讯。例如,client提交了一个导出任务,服务端执行异步任务进行处理,在导出完成时,告知客户端,客户端再下载导出结果。又比如,常见webim通讯场景,客户端发送消息,服务端接收到消息后,通知到接收方。常用的即时通讯方式有以下几种:

  • 短轮询。
  • 长轮询。
  • websocket。
  • SSE。
  • flash socket。

短轮询

短轮询,客户端周期性的向服务端发送请求(比如2s,3s,5s......),服务端收到请求后,不管是否有新消息/新通知,都立即返回。 示意图如下:

这是最简单的一种通讯方式,服务端就是普通的http接口。

当然,这种方式也是有缺点的。第一,频繁的建立连接,连接也是有开销的。第二,实时性不好,消息会有延迟。延迟的程度,要看轮询周期,比如轮询周期是3s,某次轮询之后来了一条新消息,那么客户端只有在下次轮询请求的时候才能拉到这条消息,延迟时间就约3s。

长轮询

和短轮询不同的是,在长轮询中,服务端收到请求后,如果没有新消息/新通知,并不立即返回,而是一直等待,直到达到超时时间。在等待期间,如果收到新消息/新通知,则立即返回给客户端,然后再进行下一次请求。

相比短轮询,其优点主要是:

  • 消息实时性更好。
  • 没有频繁建立网络连接的开销。

但是其缺点也是很明显:

  • 占用大量的连接。尤其是在消息不多时,其连接大多数可能是无效的。

SSE

SSE的英文全称是Server-Sent Event,直译为 服务端发送事件。在使用sse时,服务端可以向客户端推送多条消息,发送之后并不关闭连接。其本质是基于http的长连接。服务端返回的header需要是:text/event-stream。

使用sse时,服务端可以指定事件名称和事件数据,客户端可以为特定的事件指定特定的处理函数。

服务端发送消息时,每条事件消息可以指定id、data、event、retry几个属性,各个属性之间以\n分割,整条消息以\n\n结束。具体可以看下面的示例。

需要注意的是:sse是单工的,在连接维持期间,只能是服务端向客户端推送数据,客户端不能再向服务端发送消息。其更适合服务端通知场景。

下面给出例子:
vue-客户端示例

vue 复制代码
<template>
  <div class="sseTest">
    <h1>服务器发送事件(SSE)测试</h1>
    <p>{{ message }}</p>
  </div>
</template>

<script>

export default {
  data() {
    return {
      message: '等待服务器消息...',
      eventSource: null
    }
  },
  created() {
    this.testSSE()
  },
  methods: {
    testSSE() {

      this.eventSource = new EventSource('http://localhost:8080/events');


      this.eventSource.onerror = (event) => {
        console.log("error:",event)
      }


      this.eventSource.onopen = (event) => {
        console.log("open;",event)
      }

      // 监听消息 tick-event
      this.eventSource.addEventListener('tick-event', (event) => {
        console.log("tick-event:",event)
        this.message = event.data
      })

    }
  }
}
</script>

<style scoped>
</style>

服务端示例1

go 复制代码
package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

func main() {

	http.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/event-stream")
		w.Header().Set("Cache-Control", "no-cache")
		w.Header().Set("Connection", "keep-alive")
		// 允许跨域。
		w.Header().Set("Access-Control-Allow-Origin", "*")

		id := 1
		for {
			fmt.Fprintf(w, "id:%d\nevent:%s\ndata:%s\n\n", id, "tick-event", time.Now().Local().String())
			if f, ok := w.(http.Flusher); ok {
				f.Flush()
			} else {
				log.Println("Unable to send!")
			}
			time.Sleep(time.Second)
		}
	})

	log.Fatal(http.ListenAndServe(":8080", nil))

}

运行服务端程序->打开测试页面,显示结果是:

上面的例子中,我们是基于http1.1实现的,而每个浏览器&&域名,是有连接限制的。为了突破这个限制,我们下面再给一个使用http2的sse服务端程序:
服务端示例2

go 复制代码
package main

import (
	"fmt"
	"golang.org/x/net/http2"
	"golang.org/x/net/http2/h2c"
	"log"
	"net/http"
	"os"
	"time"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/event-stream")
		w.Header().Set("Cache-Control", "no-cache")
		w.Header().Set("Connection", "keep-alive")
		// 允许跨域。
		w.Header().Set("Access-Control-Allow-Origin", "*")

		id := 1
		for {
			fmt.Fprintf(w, "id:%d\nevent:%s\ndata:%s\n\n", id, "tick-event", time.Now().Local().String())
			if f, ok := w.(http.Flusher); ok {
				f.Flush()
			} else {
				log.Println("Unable to send!")
			}
			time.Sleep(time.Second)
		}
	})

	s2 := &http2.Server{}

	server := &http.Server{
		Addr:    ":8080",
		Handler: h2c.NewHandler(mux, s2),
	}

	err := http2.ConfigureServer(server, nil)

	if err != nil {
		log.Fatal(err)
	}

	// 获取程序执行路径
	pwd, _ := os.Getwd()
	fmt.Println(pwd)

	log.Fatal(server.ListenAndServeTLS("localhost-cert.pem", "localhost-privkey.pem"))
}

在上面的示例中,我使用了自己生成的ssl证书。

websocket

websocket是一种双工的通讯方式,client和server完成握手后,就可以向对方发送消息。 和上面介绍的几种方式不同的是,websocket是一种与http不同的协议,虽然两者都依赖于tcp。也正因为如此,要支持websocket,服务端和客户端都需要支持websocket协议,会比之前介绍的几种基于http的通讯方式复杂一些。

websocket有两个统一资源标识符,分别是ws和wss,ws是对应明文的,wss是对应加密连接的。

其优点是:

  • 实时性好。
  • 双工通讯。

其缺点是:

  • 相对较为复杂,需要服务端和客户端都支持websocket协议。

下面给出一个使用websocket的例子:
服务端示例

go 复制代码
package main

import (
	"log"
	"net/http"

	"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	CheckOrigin: func(r *http.Request) bool {
		return true // 允许跨域
	},
}

func echoHandler(w http.ResponseWriter, r *http.Request) {
	// 升级HTTP连接为WebSocket连接
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Println(err)
		return
	}
	defer conn.Close()

	for {
		// 从客户端接收消息
		_, message, err := conn.ReadMessage()
		if err != nil {
			log.Println(err)
			break
		}

		// 将消息原样返回给客户端
		err = conn.WriteMessage(websocket.TextMessage, []byte("服务端:"+string(message)))
		if err != nil {
			log.Println(err)
			break
		}
	}
}

func main() {
	// 设置路由,将 /echo 映射到 echoHandler 函数
	http.HandleFunc("/echo", echoHandler)

	// 启动WebSocket服务端
	err := http.ListenAndServe(":8080", nil)
	if err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}

客户端示例

vue 复制代码
<template>
  <div>
    <h1>聊天窗口</h1>
    <div class="chat-window">
      <div class="message-list" ref="messageList">
        <div v-for="(message, index) in messages" :key="index" :class="getMessageClass(message)">
          <div class="message-content">
            <span>{{ message.content }}</span>
            <span class="time">{{ message.time }}</span>
          </div>
        </div>
      </div>
      <div class="input-area">
        <input v-model="messageInput" placeholder="请输入消息" @keyup.enter="sendMessage" />
        <button @click="sendMessage">发送</button>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      messageInput: '',
      messages: [],
      socket: null
    };
  },
  mounted() {
    this.connectWebSocket();
    this.$refs.messageList.scrollTop = this.$refs.messageList.scrollHeight;
  },
  methods: {
    connectWebSocket() {
      const socket = new WebSocket('ws://localhost:8080/echo');

      socket.onopen = () => {
        console.log('WebSocket连接已打开');
      };

      socket.onmessage = (event) => {
        const receivedMessage = {
          content: event.data,
          time: this.getCurrentTime(),
          type: 'received'
        };
        this.messages.push(receivedMessage);
        this.$nextTick(() => {
          this.$refs.messageList.scrollTop = this.$refs.messageList.scrollHeight;
        });
      };

      socket.onclose = () => {
        console.log('WebSocket连接已关闭');
      };

      this.socket = socket;
    },
    sendMessage() {
      if (this.messageInput.trim() !== '') {
        const sentMessage = {
          content: this.messageInput,
          time: this.getCurrentTime(),
          type: 'sent'
        };
        this.messages.push(sentMessage);
        this.socket.send(this.messageInput);
        this.messageInput = '';
        this.$nextTick(() => {
          this.$refs.messageList.scrollTop = this.$refs.messageList.scrollHeight;
        });
      }
    },
    getCurrentTime() {
      const now = new Date();
      const hours = now.getHours().toString().padStart(2, '0');
      const minutes = now.getMinutes().toString().padStart(2, '0');
      const seconds = now.getSeconds().toString().padStart(2, '0');
      return `${hours}:${minutes}:${seconds}`;
    },
    getMessageClass(message) {
      return {
        'message-sent': message.type === 'sent',
        'message-received': message.type === 'received'
      };
    }
  }
};
</script>

<style>
.chat-window {
  display: flex;
  flex-direction: column;
  height: 400px;
  border: 1px solid #ccc;
  padding: 10px;
  background-color: #f2f2f2;
}

.message-list {
  flex: 1;
  overflow-y: scroll;
  padding: 10px;
}

.message {
  margin-bottom: 10px;
  padding: 5px;
  border-radius: 5px;
  max-width: 70%;
}

.message-content {
  display: inline-block;
  padding: 5px 10px 5px 10px;
  border-radius: 5px;
}

.message-sent {
  text-align: right;
}

.message-sent .message-content {
  background-color: lightgreen;
}

.message-received .message-content {
  background-color: white;
}

.message-received {
  text-align: left;
}

.time {
  font-size: 12px;
  color: #999;
}

.input-area {
  display: flex;
  justify-content: space-between;
  margin-top: 10px;
}

.input-area input {
  flex: 1;
  margin-right: 10px;
}

.input-area button {
  padding: 5px 10px;
}
</style>

页面测试结果如下:

flash socket

需要浏览器支持才行,并不算常见。

后记

client-server之间实现即时通信/推送的方式有好几种,在实际业务中使用哪一种(或哪几种),要根据自己的业务要求确定。如果兼容性要求高,可能需要同时支持多种方式(本人就见过一个网站同时支持websocket、flash socet、轮询三种方式的)。需求简单的,可能短轮询就能满足需求了。

注:此文原载于本人个人网站,链接地址

参考资料

本文由mdnice多平台发布

相关推荐
0zxm1 小时前
06 - Django 视图view
网络·后端·python·django
m0_748257181 小时前
Spring Boot FileUpLoad and Interceptor(文件上传和拦截器,Web入门知识)
前端·spring boot·后端
小_太_阳2 小时前
Scala_【1】概述
开发语言·后端·scala·intellij-idea
智慧老师2 小时前
Spring基础分析13-Spring Security框架
java·后端·spring
搬码后生仔3 小时前
asp.net core webapi项目中 在生产环境中 进不去swagger
chrome·后端·asp.net
凡人的AI工具箱3 小时前
每天40分玩转Django:Django国际化
数据库·人工智能·后端·python·django·sqlite
Lx3524 小时前
Pandas数据重命名:列名与索引为标题
后端·python·pandas
小池先生4 小时前
springboot启动不了 因一个spring-boot-starter-web底下的tomcat-embed-core依赖丢失
java·spring boot·后端
百罹鸟4 小时前
【vue高频面试题—场景篇】:实现一个实时更新的倒计时组件,如何确保倒计时在页面切换时能够正常暂停和恢复?
vue.js·后端·面试
小蜗牛慢慢爬行5 小时前
如何在 Spring Boot 微服务中设置和管理多个数据库
java·数据库·spring boot·后端·微服务·架构·hibernate