Server-Sent Events (SSE) Technical Analysis

What is SSE

Solutions for real-time data acquisition

For some data that needs to be updated in real time (such as Facebook/Twitter updates, valuation updates, new blog posts, event results, etc.), there are several solutions: Polling (polling) repeatedly sends data to the server on the
client
side New request. If there are no new data changes on the server, close the connection. Then the client waits for a while and then initiates a new request again, repeating these steps.
Long-polling (long polling)
In long polling, the client sends a request to the server. If there are no new data changes on the server, the connection will be maintained until updated data is received, returned to the client and the connection closed.
Server-Sent Events
SSE is similar to the long polling mechanism, but it waits for more than one data change in each connection. The client sends a request to the server, and the server keeps the request until a new message is ready, and returns the message to the client. It does not close the connection at this time, but still keeps it for other messages. A major feature of SSE is to reuse a connection to process each message (also called event).
WebSocket
WebSocket is different from the above technologies because it provides a true two-way connection. WebSocket is a very powerful new feature in HTML5 and has been widely used.

Introduction to SSE

Generally speaking, the HTTP protocol requires the client to request the server first before the server can respond to the client. It is impossible for the server to actively push information. However, there is a workaround, which is for the server to declare to the client that the next thing to be sent is event-streaming.
In other words, what is sent is not a one-time data packet, but a data stream, which will be sent continuously. At this time, the client will not close the connection and will always wait for new data streams from the server. Video playback is an example of this. In essence, this kind of communication is to complete a long download in the form of streaming information.
SSE uses this mechanism to push information to the client using stream information.

  1. The client requests to establish an event stream type connection, that is, Request Headers Accept = text/event-stream.

  1. The server responds to the request and sets the Response Headers Content-Type to text/event-stream, proving that the data will be transmitted in this type.

  1. If the server has data, it will be sent to the client. Example of use

SSE push data format

event: event type, the server can be customized, the default is message event
Id: the ID of each event stream, plays an important role in retransmitting the event stream when the event stream fails retry
: the interval between reconnection after the browser connection is disconnected, unit : Milliseconds. During the automatic reconnection process, the last event stream ID received will be sent to the server.
data: sent data.
Each field KV ends with "\n", such as:

Use of SSE

Timing with webflux

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

springMvc supports sse
server code

    @GetMapping("/sse")
    @CrossOrigin
    public SseEmitter handleSse() {
    
    
        SseEmitter emitter = new SseEmitter();

        Flux.interval(Duration.ofSeconds(1))
                .map(i -> "Server-Sent Event #" + i)
                .doOnCancel(() -> emitter.complete())
                .subscribe(
                        data -> {
    
    
                                emitter.send(data);  
                        },
                        error -> emitter.completeWithError(error),
                        () -> emitter.complete()
                );

        return emitter;
    }

postMan request
effect
image.png

SSE principle analysis

It can be found that the difference from ordinary http requests is that there is an SseEmitter object
in the request process . This object is first returned, and then messages are pushed to the browser through SseEmitter.

1. springMvc handles the returned SseEmitter

First introduce the HandlerMethodReturnValueHandler that returns the value

org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite#selectHandler

@Nullable
private HandlerMethodReturnValueHandler selectHandler(@Nullable Object value, MethodParameter returnType) {
    
    
boolean isAsyncValue = isAsyncReturnValue(value, returnType);
for (HandlerMethodReturnValueHandler handler : this.returnValueHandlers) {
    
    
    if (isAsyncValue && !(handler instanceof AsyncHandlerMethodReturnValueHandler)) {
    
    
        continue;
    }
    if (handler.supportsReturnType(returnType)) {
    
    
        return handler;
    }
}
return null;
}

springMvc has many built-in processors.
image.png
Let’s briefly introduce some of them.

  1. Directly return the string and use ViewNameMethodReturnValueHandler to find the corresponding ModelAndView rendering based on the string.
  2. The RequestResponseBodyMethodProcessor declaration annotation @ResponseBody uses this type of processing and will directly return a string. If it is a string, it will be returned directly. If it is an object serialization, it will return a string.
  3. And our SseEmitter obviously needs to be processed by ResponseBodyEmitterReturnValueHandler

二、ResponseBodyEmitterReturnValueHandler

public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
            ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
    
    <!-- -->
        // 返回值为空处理
        ...
        HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
       
        ServerHttpResponse outputMessage = new ServletServerHttpResponse(response);
        // 返回值为ResponseEntity<ResponseBodyEmitter> 或 ResponseEntity<SseEmitter>时的处理
        ...
        ServletRequest request = webRequest.getNativeRequest(ServletRequest.class);

        ResponseBodyEmitter emitter;
        if (returnValue instanceof ResponseBodyEmitter) {
    
    <!-- -->
            emitter = (ResponseBodyEmitter) returnValue;
        }else {
    
    <!-- -->
            // 这里是响应式编程解析的部分,暂时不去了解
            ....
        }
        // 默认空实现,SseEmitter中覆盖重写,设置了响应头类型为MediaType.TEXT_EVENT_STREAM
        emitter.extendResponse(outputMessage);

        // 流式场景不需要对响应缓存
        ShallowEtagHeaderFilter.disableContentCaching(request);

        // 包装响应以忽略进一步的头更改,头将在第一次写入时刷新
        outputMessage = new StreamingServletServerHttpResponse(outputMessage);

        HttpMessageConvertingHandler handler;
        try {
    
    <!-- -->
            // 这里使用了DeferredResult
            DeferredResult<?> deferredResult = new DeferredResult<>(emitter.getTimeout());
             //设置异步请求,可以在别的线程进行对response进行恢复
            WebAsyncUtils.getAsyncManager(webRequest).startDeferredResultProcessing(deferredResult, mavContainer);
            handler = new HttpMessageConvertingHandler(outputMessage, deferredResult);
        }
        catch (Throwable ex) {
    
    <!-- -->
            emitter.initializeWithError(ex);
            throw ex;
        }
        // 这块是主要逻辑
        emitter.initialize(handler);
    }

DeferredResult is introduced here

DeferredResult

1. Why use DeferredResult?

Establish a connection once and let them wait as long as possible. In this way, if new data arrives at the server at the same time, the server can directly return a response. This way we can definitely reduce the number of request and response cycles involved.

2.DeferredResult execution logic

The browser initiates an asynchronous request.
The request reaches the server and is suspended (use the browser to check the request status, which is pending at this time)
to respond to the browser. There are two situations:
3.1 Call DeferredResult.setResult(), the request is awakened, and returns Result
3.2 Timeout, return a result you set
. Browse to get a response, repeat 1 again to process the response result.

DeferredResult usage example

The logic of the code below is that as long as there is a change in the alarm status, the latest status of all devices will be obtained from the database, and then compared with the one saved in redis. If there is an update, the setResult method will be called and the result will be returned to the client. If there is no update, break out of the loop after 20 seconds.

  @GetMapping("/defferResult")
    @CrossOrigin
    @ResponseBody
    public DeferredResult defferResult() {
    
    
        DeferredResult deferredResult = new DeferredResult(3*1000l);
        Executors.newSingleThreadExecutor().execute(()->{
    
    
           try {
    
    
               sleep(4000l);
           } catch (InterruptedException e) {
    
    
               e.printStackTrace();
           }
             deferredResult.setResult("sucess");
        
        });
        return deferredResult;
    }

If it times out, an AsyncRequestTimeoutException exception will be thrown and handled by springmvc. The return value will also be processed again by springMvc.
For example, a timeout exception is thrown, which can be processed by us.
image.png
Therefore, DeferredResult will be parsed by springMvc twice, once to return its own deferredResult, and once to set the result.
In other words, DeferredResult is used to suspend http requests.

3. Initialize HttpMessageConvertingHandler

	handler = new HttpMessageConvertingHandler(outputMessage, deferredResult);

According to what we have seen earlier,
subsequent messages are sent through this handler.

 synchronized void initialize(Handler handler) throws IOException {
    
    <!-- -->
        this.handler = handler;

        try {
    
    <!-- -->
            // 遍历之前发送的数据
            for (DataWithMediaType sendAttempt : this.earlySendAttempts) {
    
    <!-- -->
                // 这里会调用handler的send方法
                sendInternal(sendAttempt.getData(), sendAttempt.getMediaType());
            }
        }finally {
    
    <!-- -->
            this.earlySendAttempts.clear();
        }
        // 数据是否已经发完了
        if (this.complete) {
    
    <!-- -->
            // 有没有报错
            if (this.failure != null) {
    
    <!-- -->
                this.handler.completeWithError(this.failure);
            }else {
    
    <!-- -->
                // 这里最终会调用DefferedResult.setResult
                this.handler.complete();
            }
        }else {
    
    <!-- -->
            this.handler.onTimeout(this.timeoutCallback);
            this.handler.onError(this.errorCallback);
            this.handler.onCompletion(this.completionCallback);
        }
    }

What needs to be noted here is that when the handler is not initialized anyway, the messages sent by calling SseEmitter's send are temporarily stored in earlySendAttempts. When the initialization is completed, the previously buffered messages are first sent.

     this.handler.onTimeout(this.timeoutCallback);
     this.handler.onError(this.errorCallback);
     this.handler.onCompletion(this.completionCallback);

Set the onTimeout, onError, and onCompletion methods of DeferredResult respectively.
Generally speaking, the sse method still uses the DeferredResult asynchronous response method.

3. Message sending

First call the send method of emitter

@Override
	public void send(Object object) throws IOException {
    
    
		send(object, null);
	}
@Override
	public void send(Object object, @Nullable MediaType mediaType) throws IOException {
    
    
		send(event().data(object, mediaType));
	}

Here we find that the message body of the event in this way of sending messages only has the data attribute, but not the id and retry attributes.
If you need attributes such as id, you can write like this

@GetMapping("/sse")
    @CrossOrigin
    @ResponseBody
    public SseEmitter handleSse() {
    
    
        SseEmitter emitter = new SseEmitter(20000l);

        AtomicInteger atomicInteger = new AtomicInteger();
        Flux.interval(Duration.ofSeconds(1))
                .map(i -> "Server-Sent Event #" + i)
                .doOnCancel(() -> emitter.complete())
                .subscribe(
                        data -> {
    
    
                            try {
    
    
                                emitter.send(SseEmitter.event()
                                        .id(String.valueOf(atomicInteger.getAndAdd(1)))
                                        .name("message")
                                        .data("Message " + atomicInteger.get()));
                        //        emitter.complete();
                            } catch (IOException e) {
    
    
                                emitter.completeWithError(new RuntimeException("发送错误"));
                            }
                        },
                        error -> emitter.completeWithError(error),
                        () -> emitter.complete()
                );

        return emitter;
    }

If the client receives the id and the connection is disconnected before the message is accepted, it can request the server again through the last id, and the server will continue to send messages based on the id. Perform short-term reconnection operations
and build events to send.

public void send(SseEventBuilder builder) throws IOException {
    
    
		Set<DataWithMediaType> dataToSend = builder.build();
		synchronized (this) {
    
    
			for (DataWithMediaType entry : dataToSend) {
    
    
				super.send(entry.getData(), entry.getMediaType());
			}
		}

image.png
Sending them three times respectively constitutes
data: hello world\n\nThe data format of a message, which the browser can parse accordingly.

private class HttpMessageConvertingHandler implements ResponseBodyEmitter.Handler {
    
    <!-- -->
        ...
       
        @SuppressWarnings("unchecked")
        private <T> void sendInternal(T data, @Nullable MediaType mediaType) throws IOException {
    
    <!-- -->
            // RequestMappingHandlerAdapter实例化的时候会设置,例如ByteArrayHttpMessageConverter,StringHttpMessageConverter
            for (HttpMessageConverter<?> converter : ResponseBodyEmitterReturnValueHandler.this.sseMessageConverters) {
    
    <!-- -->
                if (converter.canWrite(data.getClass(), mediaType)) {
    
    <!-- -->
                    // 将消息写入输出流
                    ((HttpMessageConverter<T>) converter).write(data, mediaType, this.outputMessage);
                    this.outputMessage.flush();
                    return;
                }
            }
            throw new IllegalArgumentException("No suitable converter for " + data.getClass());
        }
    }

While the http request is pending, you can also write to ServerHttpResponse and continuously send it to the browser.

4. End the http request

After sending, execute emitter.complete() to complete the request and end the suspension of the http request.
An error occurs during sending, and the error message is sent to the browser.

5. Thoughts on timeout time

SseEmitter emitter = new SseEmitter(1000l);

You can specify a timeout when constructing SseEmitter, which is actually the timeout of DeferredResult.
When the timeout is reached, the connection is automatically released, so you need to set an appropriate timeout.

java as client using sse

Need to use okhttp3

   <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp-sse</artifactId>
            <version>3.14.9</version>
        </dependency>

Writing a response listener

package com.unfbx.chatgpt.sse;

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okhttp3.sse.EventSource;
import okhttp3.sse.EventSourceListener;

import java.util.Objects;

/**
 * 描述: sse
 *
 * @author https:www.unfbx.com
 * 2023-02-28
 */
@Slf4j
public class ConsoleEventSourceListener extends EventSourceListener {
    
    

    @Override
    public void onOpen(EventSource eventSource, Response response) {
    
    
        log.info("OpenAI建立sse连接...");
    }

    @Override
    public void onEvent(EventSource eventSource, String id, String type, String data) {
    
    
        log.info("OpenAI返回数据:{}", data);
        if (data.equals("[DONE]")) {
    
    
            log.info("OpenAI返回数据结束了");
            return;
        }
    }

    @Override
    public void onClosed(EventSource eventSource) {
    
    
        log.info("OpenAI关闭sse连接...");
    }

    @SneakyThrows
    @Override
    public void onFailure(EventSource eventSource, Throwable t, Response response) {
    
    
        if(Objects.isNull(response)){
    
    
            log.error("OpenAI  sse连接异常:{}", t);
            eventSource.cancel();
            return;
        }
        ResponseBody body = response.body();
        if (Objects.nonNull(body)) {
    
    
            log.error("OpenAI  sse连接异常data:{},异常:{}", body.string(), t);
        } else {
    
    
            log.error("OpenAI  sse连接异常data:{},异常:{}", response, t);
        }
        eventSource.cancel();
    }
}

Send a request and set a listener to listen for sse events

  public static void main(String[] args) {
    
    

        try {
    
    
            OkHttpClient okHttpClient = new OkHttpClient
                    .Builder()
                    .connectTimeout(30, TimeUnit.SECONDS)
                    .writeTimeout(30, TimeUnit.SECONDS)
                    .readTimeout(30, TimeUnit.SECONDS)
                    .build();

            EventSource.Factory factory = EventSources.createFactory(okHttpClient);
            Request request = new Request.Builder()
                    .url("http://localhost:8062/user/sse")
                    .get()
                    .build();
            //创建事件
            EventSource eventSource = factory.newEventSource(request, new ConsoleEventSourceListener());
        } catch (Exception e) {
    
    
            log.error("请求参数解析异常:{}", e);
            e.printStackTrace();
        }

    }

Guess you like

Origin blog.csdn.net/qq_37436172/article/details/131070491