切換語言為:簡體

Spring Boot 整合 SSE 實現ChatGPT流式互動

  • 爱糖宝
  • 2024-05-29
  • 2192
  • 0
  • 0

1.什麼是sse?

SSE(Server-Sent Events)是一種允許伺服器向客戶端推送實時資料的技術,它建立在 HTTP 和簡單文字格式之上,提供了一種輕量級的伺服器推送方式,通常也被稱為“事件流”(Event Stream)。他透過在客戶端和服務端之間建立一個長連線,並透過這條連線實現服務端和客戶端的訊息實時推送。

SSE的基本特性:

  • HTML5中的協議,是基於純文字的簡單協議;

  • 在遊覽器端可供JavaScript使用的EventSource物件

EventSource提供了三個標準事件,同時預設支援斷線重連

事件 描述

EventSource提供了三個標準事件,同時預設支援斷線重連

事件 描述
onopen 當成功與伺服器建立連線時產生
onmessage 當收到伺服器發來的訊息時發生
onerror 當出現錯誤時發生

傳輸的資料有格式上的要求,必須為 [data:...\n...\n]或者是[retry:10\n]

SSE和WebSocket

提到SSE,那自然要提一下WebSocket了。WebSocket是一種HTML5提供的全雙工通訊協議(指可以在同一時間內允許兩個裝置之間進行雙向傳送和接收資料的通訊協議),基於TCP協議,並複用HTTP的握手通道(允許一次TCP連線中傳輸多個HTTP請求和相應),常用於瀏覽器與伺服器之間的實時通訊。 SSE和WebSocket儘管功能類似,都是用來實現伺服器向客戶端實時推送資料的技術,但還是有一定區別:

1.SSE (Server-Sent Events)

  • 簡單性:SSE 使用簡單的 HTTP 協議,通常建立在標準的 HTTP 或 HTTPS 連線之上。這使得它對於一些簡單的實時通知場景非常適用,特別是對於伺服器向客戶端單向推送資料。

  • 相容性:SSE 在瀏覽器端具有較好的相容性,因為它是基於標準的 HTTP 協議的。即使在一些不支援 WebSocket 的環境中,SSE 仍然可以被支援。

  • 適用範圍:SSE 適用於伺服器向客戶端單向推送通知,例如實時更新、事件通知等。但它僅支援從伺服器到客戶端的單向通訊,客戶端無法直接向伺服器傳送訊息。

2.WebSocket

  • 全雙工通訊: WebSocket 提供了全雙工通訊,允許客戶端和伺服器之間進行雙向實時通訊。這使得它適用於一些需要雙向資料交換的應用,比如線上聊天、實時協作等。

  • 低延遲:WebSocket 的通訊開銷相對較小,因為它使用單一的持久連線,而不像 SSE 需要不斷地建立新的連線。這可以降低通訊的延遲。

  • 適用範圍: WebSocket 適用於需要實時雙向通訊的應用,特別是對於那些需要低延遲、高頻率訊息交換的場景。

3.選擇 SSE 還是 WebSocket?

  • 簡單通知場景:如果你只需要伺服器向客戶端推送簡單的通知、事件更新等,而不需要客戶端與伺服器進行雙向通訊,那麼 SSE 是一個簡單而有效的選擇。

  • 雙向通訊場景:如果你的應用需要實現實時雙向通訊,例如線上聊天、協作編輯等,那麼 WebSocket 是更合適的選擇。

  • 相容性考慮: 如果你的應用可能在一些不支援 WebSocket 的環境中執行,或者需要考慮到更廣泛的瀏覽器相容性,那麼 SSE 可能是一個更可行的選擇。

2.程式碼工程

實驗目標:實現chatgpt流式互動

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>springboot-demo</artifactId>
        <groupId>com.et</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>sse</artifactId>
    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-autoconfigure</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- java基礎工具包 -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.9</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>
</project>

controller

package com.et.sse.controller;
import cn.hutool.core.util.IdUtil;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Controller
@RequestMapping("/chat")
public class ChatController {
    Map<String, String> msgMap = new ConcurrentHashMap<>();
    /**
     * send meaaage
     * @param msg
     * @return
     */
    @ResponseBody
    @PostMapping("/sendMsg")
    public String sendMsg(String msg) {
        String msgId = IdUtil.simpleUUID();
        msgMap.put(msgId, msg);
        return msgId;
    }
    /**
     * conversation
     * @param msgId mapper with sendmsg
     * @return
     */
    @GetMapping(value = "/conversation/{msgId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter conversation(@PathVariable("msgId") String msgId) {
        SseEmitter emitter = new SseEmitter();
        String msg = msgMap.remove(msgId);
        //mock chatgpt response
        new Thread(() -> {
            try {
                for (int i = 0; i < 10; i++) {
                    ChatMessage  chatMessage =  new ChatMessage("test", new String(i+""));
                    emitter.send(chatMessage);
                    Thread.sleep(1000);
                }
                emitter.send(SseEmitter.event().name("stop").data(""));
                emitter.complete(); // close connection
            } catch (IOException | InterruptedException e) {
                emitter.completeWithError(e); // error finish
            }
        }).start();
        return emitter;
    }
}

chat.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>ChatGpt test</title>
    <link rel="stylesheet" href="lib/element-ui/index.css">
    <style type="text/css">
        body{
            background-color:white;
        }
        #outputCard{
            height: 300px;
            overflow:auto;
        }
        #inputCard{
            height: 100px;
            overflow:auto;
        }
        #outputBody{
            line-height:30px;
        }
        .cursor-img{
            height:24px;
            vertical-align: text-bottom;
        }
    </style>
    <script src="lib/jquery/jquery-3.6.0.min.js"></script>
    <script src="lib/vue/vue.min.js"></script>
    <script src="lib/element-ui/index.js"></script>
</head>
<body>
<h1>ChatGpt Test</h1>
<div id="chatWindow">
    <el-row id="outputArea">
        <el-card id="inputCard">
            <div id="inputTxt">
            </div>
        </el-card>
        <el-card id="outputCard">
            <div id="outputBody">
                <span id="outputTxt"></span>
                <img v-if="blink" src="img/cursor-text-blink.gif" v-show="cursorImgVisible">
                <img v-if="!blink" src="img/cursor-text-black.png" v-show="cursorImgVisible">
            </div>
        </el-card>
    </el-row>
    <el-row id="inputArea">
        <el-col :span="21">
            <el-input id="sendTxt" v-model="input" placeholder="input content" @keyup.native="keyUp"></el-input>
        </el-col>
        <el-col :span="3">
            <el-button id="sendBtn" type="primary" :disabled="sendBtnDisabled" @click="sendMsg">send</el-button>
        </el-col>
    </el-row>
</div>
</body>
<script type="text/javascript">
    var app = new Vue({
      el: '#chatWindow',
      data: {
          input: '',
          sendBtnDisabled: false,
          cursorImgVisible: false,
          blink: true
      },
      mounted: function(){
      },
      methods: {
         keyUp: function(event){
            if(event.keyCode==13){
               this.sendMsg();
            }
         },
         sendMsg: function(){
             var that = this;
             //init
             $('#outputTxt').html('');
             var sendTxt = $('#sendTxt').val();
             $('#inputTxt').html(sendTxt);
             $('#sendTxt').val('');
             that.sendBtnDisabled = true;
             that.cursorImgVisible = true;
             //send request
             $.ajax({
                type: "post",
                url:"/chat/sendMsg",
                data:{
                    msg: sendTxt
                },
                contentType: 'application/x-www-form-urlencoded',
                success:function(data){
                     var eventSource = new EventSource('/chat/conversation/'+data)
                     eventSource.addEventListener('open', function(e) {
                        console.log("EventSource連線成功");
                     });
                     var blinkTimeout = null;
                     eventSource.addEventListener("message", function(evt){
                        var data = evt.data;
                        var json = JSON.parse(data);
                        var content = json.content ? json.content : '';
                        content = content.replaceAll('\n','<br/>');
                        console.log(json)
                        var outputTxt = $('#outputTxt');
                        outputTxt.html(outputTxt.html()+content);
                        var outputCard = $('#outputCard');
                        var scrollHeight = outputCard[0].scrollHeight;
                        outputCard.scrollTop(scrollHeight);
                        //cusor blink
                        that.blink = false;
                        window.clearTimeout(blinkTimeout);
                        //200ms blink=true
                        blinkTimeout = window.setTimeout(function(){
                            that.blink = true;
                        }, 200)
                    });
                    eventSource.addEventListener('error', function (e) {
                        console.log("EventSource error");
                        if (e.target.readyState === EventSource.CLOSED) {
                          console.log('Disconnected');
                        } else if (e.target.readyState === EventSource.CONNECTING) {
                          console.log('Connecting...');
                        }
                    });
                    eventSource.addEventListener('stop', e => {
                        console.log('EventSource連線結束');
                        eventSource.close();
                        that.sendBtnDisabled = false;
                        that.cursorImgVisible = false;
                    }, false);
                },
                error: function(){
                     that.sendBtnDisabled = false;
                     that.cursorImgVisible = false;
                }
             });
         }
      }
    })
</script>
</html>

以上只是一些關鍵程式碼,所有程式碼請參見下面程式碼倉庫

程式碼倉庫

  • https://github.com/Harries/springboot-demo

3.測試

啟動Spring Boot應用

測試流式互動

訪問http://127.0.0.1:8088/chat.html


0則評論

您的電子郵件等資訊不會被公開,以下所有項目均必填

OK! You can skip this field.