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多平台发布

相关推荐
wowocpp1 小时前
spring boot Controller 和 RestController 的区别
java·spring boot·后端
后青春期的诗go1 小时前
基于Rust语言的Rocket框架和Sqlx库开发WebAPI项目记录(二)
开发语言·后端·rust·rocket框架
freellf1 小时前
go语言学习进阶
后端·学习·golang
全栈派森3 小时前
云存储最佳实践
后端·python·程序人生·flask
CircleMouse4 小时前
基于 RedisTemplate 的分页缓存设计
java·开发语言·后端·spring·缓存
獨枭5 小时前
使用 163 邮箱实现 Spring Boot 邮箱验证码登录
java·spring boot·后端
维基框架5 小时前
Spring Boot 封装 MinIO 工具
java·spring boot·后端
秋野酱5 小时前
基于javaweb的SpringBoot酒店管理系统设计与实现(源码+文档+部署讲解)
java·spring boot·后端
☞无能盖世♛逞何英雄☜5 小时前
Flask框架搭建
后端·python·flask
进击的雷神5 小时前
Perl语言深度考查:从文本处理到正则表达式的全面掌握
开发语言·后端·scala