java實現(xiàn)web實時消息推送的七種方案
引言
做了一個小破站,現(xiàn)在要實現(xiàn)一個站內信web消息推送的功能,對,就是下圖這個小紅點,一個很常用的功能。

不過他還沒想好用什么方式做,這里我?guī)退砹艘幌聨追N方案,并簡單做了實現(xiàn)。

什么是消息推送(push)
推送的場景比較多,比如有人關注我的公眾號,這時我就會收到一條推送消息,以此來吸引我點擊打開應用。
消息推送(push)通常是指網站的運營工作等人員,通過某種工具對用戶當前網頁或移動設備APP進行的主動消息推送。
消息推送一般又分為web端消息推送和移動端消息推送。

上邊的這種屬于移動端消息推送,web端消息推送常見的諸如站內信、未讀郵件數(shù)量、監(jiān)控報警數(shù)量等,應用的也非常廣泛。

在具體實現(xiàn)之前,咱們再來分析一下前邊的需求,其實功能很簡單,只要觸發(fā)某個事件(主動分享了資源或者后臺主動推送消息),web頁面的通知小紅點就會實時的+1就可以了。
通常在服務端會有若干張消息推送表,用來記錄用戶觸發(fā)不同事件所推送不同類型的消息,前端主動查詢(拉)或者被動接收(推)用戶所有未讀的消息數(shù)。

消息推送無非是推(push)和拉(pull)兩種形式,下邊我們逐個了解下。
短輪詢
輪詢(polling)應該是實現(xiàn)消息推送方案中最簡單的一種,這里我們暫且將輪詢分為短輪詢和長輪詢。
短輪詢很好理解,指定的時間間隔,由瀏覽器向服務器發(fā)出HTTP請求,服務器實時返回未讀消息數(shù)據給客戶端,瀏覽器再做渲染顯示。
一個簡單的JS定時器就可以搞定,每秒鐘請求一次未讀消息數(shù)接口,返回的數(shù)據展示即可。
setInterval(() => {
// 方法請求
messageCount().then((res) => {
if (res.code === 200) {
this.messageCount = res.data
}
})
}, 1000);
效果還是可以的,短輪詢實現(xiàn)固然簡單,缺點也是顯而易見,由于推送數(shù)據并不會頻繁變更,無論后端此時是否有新的消息產生,客戶端都會進行請求,勢必會對服務端造成很大壓力,浪費帶寬和服務器資源。

長輪詢
長輪詢是對上邊短輪詢的一種改進版本,在盡可能減少對服務器資源浪費的同時,保證消息的相對實時性。長輪詢在中間件中應用的很廣泛,比如Nacos和apollo配置中心,消息隊列kafka、RocketMQ中都有用到長輪詢。
Nacos配置中心交互模型是push還是pull?一文中我詳細介紹過Nacos長輪詢的實現(xiàn)原理,感興趣的小伙伴可以瞅瞅。
這次我使用apollo配置中心實現(xiàn)長輪詢的方式,應用了一個類

DeferredResult可以允許容器線程快速釋放占用的資源,不阻塞請求線程,以此接受更多的請求提升系統(tǒng)的吞吐量,然后啟動異步工作線程處理真正的業(yè)務邏輯,處理完成調用DeferredResult.setResult(200)提交響應結果。
下邊我們用長輪詢來實現(xiàn)消息推送。
因為一個ID可能會被多個長輪詢請求監(jiān)聽,所以我采用了guava包提供的Multimap結構存放長輪詢,一個key可以對應多個value。一旦監(jiān)聽到key發(fā)生變化,對應的所有長輪詢都會響應。前端得到非請求超時的狀態(tài)碼,知曉數(shù)據變更,主動查詢未讀消息數(shù)接口,更新頁面數(shù)據。
@Controller
@RequestMapping("/polling")
public class PollingController {
// 存放監(jiān)聽某個Id的長輪詢集合
// 線程同步結構
public static Multimap<String, DeferredResult<String>> watchRequests = Multimaps.synchronizedMultimap(HashMultimap.create());
/**
* 公眾號:程序員小富
* 設置監(jiān)聽
*/
@GetMapping(path = "watch/{id}")
@ResponseBody
public DeferredResult<String> watch(@PathVariable String id) {
// 延遲對象設置超時時間
DeferredResult<String> deferredResult = new DeferredResult<>(TIME_OUT);
// 異步請求完成時移除 key,防止內存溢出
deferredResult.onCompletion(() -> {
watchRequests.remove(id, deferredResult);
});
// 注冊長輪詢請求
watchRequests.put(id, deferredResult);
return deferredResult;
}
/**
* 公眾號:程序員小富
* 變更數(shù)據
*/
@GetMapping(path = "publish/{id}")
@ResponseBody
public String publish(@PathVariable String id) {
// 數(shù)據變更 取出監(jiān)聽ID的所有長輪詢請求,并一一響應處理
if (watchRequests.containsKey(id)) {
Collection<DeferredResult<String>> deferredResults = watchRequests.get(id);
for (DeferredResult<String> deferredResult : deferredResults) {
deferredResult.setResult("我更新了" + new Date());
}
}
return "success";
}
當請求超過設置的超時時間,會拋出AsyncRequestTimeoutException異常,這里直接用@ControllerAdvice全局捕獲統(tǒng)一返回即可,前端獲取約定好的狀態(tài)碼后再次發(fā)起長輪詢請求,如此往復調用。
@ControllerAdvice
public class AsyncRequestTimeoutHandler {
@ResponseStatus(HttpStatus.NOT_MODIFIED)
@ResponseBody
@ExceptionHandler(AsyncRequestTimeoutException.class)
public String asyncRequestTimeoutHandler(AsyncRequestTimeoutException e) {
System.out.println("異步請求超時");
return "304";
}
}
我們來測試一下,首先頁面發(fā)起長輪詢請求/polling/watch/10086監(jiān)聽消息更變,請求被掛起,不變更數(shù)據直至超時,再次發(fā)起了長輪詢請求;緊接著手動變更數(shù)據/polling/publish/10086,長輪詢得到響應,前端處理業(yè)務邏輯完成后再次發(fā)起請求,如此循環(huán)往復。

長輪詢相比于短輪詢在性能上提升了很多,但依然會產生較多的請求,這是它的一點不完美的地方。
iframe流
iframe流就是在頁面中插入一個隱藏的<iframe>標簽,通過在src中請求消息數(shù)量API接口,由此在服務端和客戶端之間創(chuàng)建一條長連接,服務端持續(xù)向iframe傳輸數(shù)據。
傳輸?shù)臄?shù)據通常是HTML、或是內嵌的javascript腳本,來達到實時更新頁面的效果。

這種方式實現(xiàn)簡單,前端只要一個<iframe>標簽搞定了
<iframe src="/iframe/message" style="display:none"></iframe>
服務端直接組裝html、js腳本數(shù)據向response寫入就行了
@Controller
@RequestMapping("/iframe")
public class IframeController {
@GetMapping(path = "message")
public void message(HttpServletResponse response) throws IOException, InterruptedException {
while (true) {
response.setHeader("Pragma", "no-cache");
response.setDateHeader("Expires", 0);
response.setHeader("Cache-Control", "no-cache,no-store");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().print(" <script type=\"text/javascript\">\n" +
"parent.document.getElementById('clock').innerHTML = \"" + count.get() + "\";" +
"parent.document.getElementById('count').innerHTML = \"" + count.get() + "\";" +
"</script>");
}
}
}
但我個人不推薦,因為它在瀏覽器上會顯示請求未加載完,圖標會不停旋轉,簡直是強迫癥殺手。

SSE (我的方式)
很多人可能不知道,服務端向客戶端推送消息,其實除了可以用WebSocket這種耳熟能詳?shù)臋C制外,還有一種服務器發(fā)送事件(Server-sent events),簡稱SSE。
SSE它是基于HTTP協(xié)議的,我們知道一般意義上的HTTP協(xié)議是無法做到服務端主動向客戶端推送消息的,但SSE是個例外,它變換了一種思路。

SSE在服務器和客戶端之間打開一個單向通道,服務端響應的不再是一次性的數(shù)據包而是text/event-stream類型的數(shù)據流信息,在有數(shù)據變更時從服務器流式傳輸?shù)娇蛻舳恕?/p>
整體的實現(xiàn)思路有點類似于在線視頻播放,視頻流會連續(xù)不斷的推送到瀏覽器,你也可以理解成,客戶端在完成一次用時很長(網絡不暢)的下載。

SSE與WebSocket作用相似,都可以建立服務端與瀏覽器之間的通信,實現(xiàn)服務端向客戶端推送消息,但還是有些許不同:
- SSE 是基于HTTP協(xié)議的,它們不需要特殊的協(xié)議或服務器實現(xiàn)即可工作;
WebSocket需單獨服務器來處理協(xié)議。 - SSE 單向通信,只能由服務端向客戶端單向通信;webSocket全雙工通信,即通信的雙方可以同時發(fā)送和接受信息。
- SSE 實現(xiàn)簡單開發(fā)成本低,無需引入其他組件;WebSocket傳輸數(shù)據需做二次解析,開發(fā)門檻高一些。
- SSE 默認支持斷線重連;WebSocket則需要自己實現(xiàn)。
- SSE 只能傳送文本消息,二進制數(shù)據需要經過編碼后傳送;WebSocket默認支持傳送二進制數(shù)據。
SSE 與 WebSocket 該如何選擇?
技術并沒有好壞之分,只有哪個更合適
SSE好像一直不被大家所熟知,一部分原因是出現(xiàn)了WebSockets,這個提供了更豐富的協(xié)議來執(zhí)行雙向、全雙工通信。對于游戲、即時通信以及需要雙向近乎實時更新的場景,擁有雙向通道更具吸引力。
但是,在某些情況下,不需要從客戶端發(fā)送數(shù)據。而你只需要一些服務器操作的更新。比如:站內信、未讀消息數(shù)、狀態(tài)更新、股票行情、監(jiān)控數(shù)量等場景,SEE不管是從實現(xiàn)的難易和成本上都更加有優(yōu)勢。此外,SSE 具有WebSockets在設計上缺乏的多種功能,例如:自動重新連接、事件ID和發(fā)送任意事件的能力。
前端只需進行一次HTTP請求,帶上唯一ID,打開事件流,監(jiān)聽服務端推送的事件就可以了
<script>
let source = null;
let userId = 7777
if (window.EventSource) {
// 建立連接
source = new EventSource('http://localhost:7777/sse/sub/'+userId);
setMessageInnerHTML("連接用戶=" + userId);
/**
* 連接一旦建立,就會觸發(fā)open事件
* 另一種寫法:source.onopen = function (event) {}
*/
source.addEventListener('open', function (e) {
setMessageInnerHTML("建立連接。。。");
}, false);
/**
* 客戶端收到服務器發(fā)來的數(shù)據
* 另一種寫法:source.onmessage = function (event) {}
*/
source.addEventListener('message', function (e) {
setMessageInnerHTML(e.data);
});
} else {
setMessageInnerHTML("你的瀏覽器不支持SSE");
}
</script>
服務端的實現(xiàn)更簡單,創(chuàng)建一個SseEmitter對象放入sseEmitterMap進行管理
private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();
/**
* 創(chuàng)建連接
*
* @date: 2022/7/12 14:51
* @auther: 公眾號:程序員小富
*/
public static SseEmitter connect(String userId) {
try {
// 設置超時時間,0表示不過期。默認30秒
SseEmitter sseEmitter = new SseEmitter(0L);
// 注冊回調
sseEmitter.onCompletion(completionCallBack(userId));
sseEmitter.onError(errorCallBack(userId));
sseEmitter.onTimeout(timeoutCallBack(userId));
sseEmitterMap.put(userId, sseEmitter);
count.getAndIncrement();
return sseEmitter;
} catch (Exception e) {
log.info("創(chuàng)建新的sse連接異常,當前用戶:{}", userId);
}
return null;
}
/**
* 給指定用戶發(fā)送消息
*
* @date: 2022/7/12 14:51
* @auther: 公眾號:程序員小富
*/
public static void sendMessage(String userId, String message) {
if (sseEmitterMap.containsKey(userId)) {
try {
sseEmitterMap.get(userId).send(message);
} catch (IOException e) {
log.error("用戶[{}]推送異常:{}", userId, e.getMessage());
removeUser(userId);
}
}
}
我們模擬服務端推送消息,看下客戶端收到了消息,和我們預期的效果一致。

注意: SSE不支持IE瀏覽器,對其他主流瀏覽器兼容性做的還不錯。

MQTT
什么是 MQTT協(xié)議?
MQTT 全稱(Message Queue Telemetry Transport):一種基于發(fā)布/訂閱(publish/subscribe)模式的輕量級通訊協(xié)議,通過訂閱相應的主題來獲取消息,是物聯(lián)網(Internet of Thing)中的一個標準傳輸協(xié)議。
該協(xié)議將消息的發(fā)布者(publisher)與訂閱者(subscriber)進行分離,因此可以在不可靠的網絡環(huán)境中,為遠程連接的設備提供可靠的消息服務,使用方式與傳統(tǒng)的MQ有點類似。

TCP協(xié)議位于傳輸層,MQTT 協(xié)議位于應用層,MQTT 協(xié)議構建于TCP/IP協(xié)議上,也就是說只要支持TCP/IP協(xié)議棧的地方,都可以使用MQTT協(xié)議。
為什么要用 MQTT協(xié)議?
MQTT協(xié)議為什么在物聯(lián)網(IOT)中如此受偏愛?而不是其它協(xié)議,比如我們更為熟悉的 HTTP協(xié)議呢?
- 首先
HTTP協(xié)議它是一種同步協(xié)議,客戶端請求后需要等待服務器的響應。而在物聯(lián)網(IOT)環(huán)境中,設備會很受制于環(huán)境的影響,比如帶寬低、網絡延遲高、網絡通信不穩(wěn)定等,顯然異步消息協(xié)議更為適合IOT應用程序。 HTTP是單向的,如果要獲取消息客戶端必須發(fā)起連接,而在物聯(lián)網(IOT)應用程序中,設備或傳感器往往都是客戶端,這意味著它們無法被動地接收來自網絡的命令。- 通常需要將一條命令或者消息,發(fā)送到網絡上的所有設備上。
HTTP要實現(xiàn)這樣的功能不但很困難,而且成本極高。
具體的MQTT協(xié)議介紹和實踐,這里我就不再贅述了,大家可以參考我之前的兩篇文章,里邊寫的也都很詳細了。
MQTT協(xié)議的介紹
我也沒想到 springboot + rabbitmq 做智能家居,會這么簡單
MQTT實現(xiàn)消息推送
未讀消息(小紅點),前端 與 RabbitMQ 實時消息推送實踐,賊簡單~
Websocket
websocket應該是大家都比較熟悉的一種實現(xiàn)消息推送的方式,上邊我們在講SSE的時候也和websocket進行過比較。
WebSocket是一種在TCP連接上進行全雙工通信的協(xié)議,建立客戶端和服務器之間的通信渠道。瀏覽器和服務器僅需一次握手,兩者之間就直接可以創(chuàng)建持久性的連接,并進行雙向數(shù)據傳輸。

springboot整合websocket,先引入websocket相關的工具包,和SSE相比額外的開發(fā)成本。
<!-- 引入websocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
服務端使用@ServerEndpoint注解標注當前類為一個websocket服務器,客戶端可以通過ws://localhost:7777/webSocket/10086來連接到WebSocket服務器端。
@Component
@Slf4j
@ServerEndpoint("/websocket/{userId}")
public class WebSocketServer {
//與某個客戶端的連接會話,需要通過它來給客戶端發(fā)送數(shù)據
private Session session;
private static final CopyOnWriteArraySet<WebSocketServer> webSockets = new CopyOnWriteArraySet<>();
// 用來存在線連接數(shù)
private static final Map<String, Session> sessionPool = new HashMap<String, Session>();
/**
* 公眾號:程序員小富
* 鏈接成功調用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam(value = "userId") String userId) {
try {
this.session = session;
webSockets.add(this);
sessionPool.put(userId, session);
log.info("websocket消息: 有新的連接,總數(shù)為:" + webSockets.size());
} catch (Exception e) {
}
}
/**
* 公眾號:程序員小富
* 收到客戶端消息后調用的方法
*/
@OnMessage
public void onMessage(String message) {
log.info("websocket消息: 收到客戶端消息:" + message);
}
/**
* 公眾號:程序員小富
* 此為單點消息
*/
public void sendOneMessage(String userId, String message) {
Session session = sessionPool.get(userId);
if (session != null && session.isOpen()) {
try {
log.info("websocket消: 單點消息:" + message);
session.getAsyncRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
前端初始化打開WebSocket連接,并監(jiān)聽連接狀態(tài),接收服務端數(shù)據或向服務端發(fā)送數(shù)據。
<script>
var ws = new WebSocket('ws://localhost:7777/webSocket/10086');
// 獲取連接狀態(tài)
console.log('ws連接狀態(tài):' + ws.readyState);
//監(jiān)聽是否連接成功
ws.onopen = function () {
console.log('ws連接狀態(tài):' + ws.readyState);
//連接成功則發(fā)送一個數(shù)據
ws.send('test1');
}
// 接聽服務器發(fā)回的信息并處理展示
ws.onmessage = function (data) {
console.log('接收到來自服務器的消息:');
console.log(data);
//完成通信后關閉WebSocket連接
ws.close();
}
// 監(jiān)聽連接關閉事件
ws.onclose = function () {
// 監(jiān)聽整個過程中websocket的狀態(tài)
console.log('ws連接狀態(tài):' + ws.readyState);
}
// 監(jiān)聽并處理error事件
ws.onerror = function (error) {
console.log(error);
}
function sendMessage() {
var content = $("#message").val();
$.ajax({
url: '/socket/publish?userId=10086&message=' + content,
type: 'GET',
data: { "id": "7777", "content": content },
success: function (data) {
console.log(data)
}
})
}
</script>
頁面初始化建立websocket連接,之后就可以進行雙向通信了,效果還不錯


>
自定義推送
上邊我們給我出了6種方案的原理和代碼實現(xiàn),但在實際業(yè)務開發(fā)過程中,不能盲目的直接拿過來用,還是要結合自身系統(tǒng)業(yè)務的特點和實際場景來選擇合適的方案。
推送最直接的方式就是使用第三推送平臺,畢竟錢能解決的需求都不是問題,無需復雜的開發(fā)運維,直接可以使用,省時、省力、省心,像goEasy、極光推送都是很不錯的三方服務商。
一般大型公司都有自研的消息推送平臺,像我們本次實現(xiàn)的web站內信只是平臺上的一個觸點而已,短信、郵件、微信公眾號、小程序凡是可以觸達到用戶的渠道都可以接入進來。

消息推送系統(tǒng)內部是相當復雜的,諸如消息內容的維護審核、圈定推送人群、觸達過濾攔截(推送的規(guī)則頻次、時段、數(shù)量、黑白名單、關鍵詞等等)、推送失敗補償非常多的模塊,技術上涉及到大數(shù)據量、高并發(fā)的場景也很多。所以我們今天的實現(xiàn)方式在這個龐大的系統(tǒng)面前只是小打小鬧。
Github地址
文中所提到的案例我都一一的做了實現(xiàn),整理放在了Github上,覺得有用就 Star 一下吧!
以上就是java實現(xiàn)web實時消息推送的七種方案的詳細內容,更多關于java web實時消息推送的資料請關注腳本之家其它相關文章!
相關文章
IDEA創(chuàng)建springboot + mybatis項目全過程(步驟詳解)
這篇文章主要介紹了IDEA創(chuàng)建springboot + mybatis項目全過程及步驟詳解,本文通圖文實例代碼相結合給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-07-07
springboot application.yml使用@@pom文件配置問題
這篇文章主要介紹了springboot application.yml使用@@pom文件配置問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-07-07
IntelliJ IDEA2019 安裝lombok的實現(xiàn)
這篇文章主要介紹了IntelliJ IDEA2019 安裝lombok的實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2019-10-10
一文詳解SpringBoot如何優(yōu)雅地實現(xiàn)異步調用
SpringBoot想必大家都用過,但是大家平時使用發(fā)布的接口大都是同步的,那么你知道如何優(yōu)雅的實現(xiàn)異步呢?這篇文章就來和大家詳細聊聊2023-03-03
java中快速創(chuàng)建帶初始值的List和Map實例
下面小編就為大家?guī)硪黄猨ava中快速創(chuàng)建帶初始值的List和Map實例。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-10-10

