Server-Sent Events

什么是 SSE

我们先看一看一个标准的 HTTP Request/Response 的过程。

  • 客户端与服务器建立连接,发送 HTTP request 给服务器
  • 服务器收到客户端的 HTTP request,发送 HTTP response 给客户端
  • 完成 HTTP Request/Response 过程,断开连接

从上述流程可以看出,总是客户端主动发起数据请求,而服务器只能被动接受并发送请求数据。

现在我们再看一看 SSE 有什么不同。

  • 客户端与服务器建立连接,监听服务器事件
  • 服务器准备数据,准备完成后异步推送数据到客户端
  • 客户端监听到服务器发送数据事件,接受数据,处理数据,并继续监听服务器事件

很明显,决定发送数据时机的不再是客户端,而是服务器。

每次当服务器打算发送数据时,就会产生一个 Data Event,并发送给客户端,所以这种机制,我们又称为 SSE(Server Sent Events)。

注意

还有其他技术也支持 Server-to-Client 通信。

轮询「Poolling」

客户端向服务器重复发送的请求。如果服务器有要发送的数据,就发送数据,若没有,就发送一个标志符,告诉客户端没有新的数据。不管怎样,服务器都会关闭连接。然后客户端在经过一小段时间(比如 1 秒)后再次向服务器发起新的请求。

长轮询「Long-Poolling」

长轮询与轮询的区别在于,服务器一定会在发送完数据后再断开连接,也就是说服务器与客户端建立连接时若还没有数据需要发送,服务器不会直接关闭连接,而是等数据准备就绪并发送完成后才关闭连接。

服务器发送事件「Server-Sent Events」

SSE 和长轮询很相似,但 SSE 不是一次连接就发送一条数据(message),而是会一直保留连接,重用这一次连接发送多条数据(events)。另外,SSE 还定义了一个专用的 MIME 类型 text/event-stream,用来描述服务器发送给客户端的 Events 的格式。SSE 还提供了一个 JavaScript Client API。

WebSocket

WebSocket 提供了真正的全双工连接「Full Duplex Connection」
实现过程如下:

  • 客户端发送一个特殊的 HTTP Header 给服务器,告诉服务器需要将 HTTP 连接升级到全双工 TCP/IP WebSocket 连接
  • 如果服务器支持 WebSocket 的话,就可以建立 WebSocket 连接
  • 建立连接之后,服务器和客户端随时都可以发送数据给对方

什么时候使用 SSE

SSE 实际上提供了一种「单向发布/订阅模型」(One-Way publish-Subscribe Model)的解决方案。

  • 客户端订阅服务器的消息
  • 服务器一旦有新消息,就发布给所有已订阅的客户端

一个不错的应用场景就是一个交换消息的 RESTful service,简单来说就是多人聊天服务。

服务器:使用 Java 实现 SSE

Message 类

首先声明一个简单的 Message 类,代表交互的消息。

属性

  • id 「消息的标志符」
  • message 「消息的内容」

这里只是简单定义了 id 和 message 属性,实际上还可以添加时间戳、发送人、IP地址等属性。

public class Message {
    private long id;
    private String message;

    public Message(long id, String message) {
        this.id = id;
        this.message = message;
    }

    public long getId() {
        return id;
    }

    public String getMessage() {
        return message;
    }
}

Chat Servlet

接下来,我们来写一个 Servlet,来处理客户端请求,并返回响应内容。

属性

属性 描述
counter 为客户端的每一个连接生成唯一的id
running 终止线程
asyncContexts 存储所有从浏览器打开的连接
messageQueue 消息队列
messageStore 消息储存
@WebServlet("/chat")
public class ChatServlet extends HttpServlet {
    private AtomicLong counter = new AtomicLong();
    private boolean running;

    // 保留所有的连接
    private Map asyncContexts = new ConcurrentHashMap<>();

    // 消息队列
    private BlockingQueue messageQueue = new LinkedBlockingDeque<>();

    // 存储消息
    private List messageStore = new CopyOnWriteArrayList<>();
}

这里简化了数据存储,只是把数据存储到内存中。
实际上应该存储到数据库中,这样的话,
counter 可以由数据库自动生成,
messageStore 也不必存储数据。

线程

我们接着再 ChatServlet 中创建一个线程,用来

  • 从消息队列中取出消息
  • 存储消息
  • 转发消息给所有的客户端
// 监听消息,分发消息
private Thread notifier = new Thread(() -> {
    while (running) {
        try {
            // 从消息队列中提取消息(若无消息,阻塞)
            Message message = messageQueue.take();

            // 存储消息
            messageStore.add(message);

            // 最多存储 100 条消息
            if (messageStore.size() > 100) {
                messageStore.remove(0);
            }

            // 发送消息给所有的客户端
            for (AsyncContext asyncContext : asyncContexts.values()) {
                try {
                    sendMeassage(asyncContext.getResponse().getWriter(), message);
                } catch (IOException e) {
                    // 出现异常时移除出错的客户端
                    asyncContexts.values().remove(asyncContext);
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
});

初始化

重载 init 方法,启动线程 notifier

@Override
public void init(ServletConfig config) throws ServletException {
    super.init(config);

    // TODO: 从数据库中加载消息(100条)
    // messageStore.addAll(db.loadMessages(100));

    // 启动线程
    running = true;
    notifier.start();
}

如果使用数据库存储消息的话,还需要先从数据库中加载数据。

处理用户发送的消息

用户发送过来的消息是 form 表单里的 post 数据,所以这里重载 doPost 方法来处理用户输入的消息

  • 验证消息非空
  • 存储消息
/**
 * 接受客户端的消息「调用 AJAX」
 * 验证消息,保存到数据库
 * 将消息放入消息队列
 *
 * @param request
 * @param resp
 * @throws ServletException
 * @throws IOException
 */
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse resp) throws ServletException, IOException {

    // 设置字符编码——最好放在 Filter 中
    request.setCharacterEncoding("UTF-8");

    // 获取消息
    String message = request.getParameter("msg");

    // 验证消息,并将消息存储到数据库中
    if (message != null && !message.trim().isEmpty()) {
        try {
            // 清屏命令
            if (message.equals("#clear")) {
                messageQueue.clear();
                messageStore.clear();
                return;
            }

            // TODO:保存消息到数据库

            // 创建一条消息
            Message msg = new Message(counter.incrementAndGet(), message);
            
            // 放入消息队列
            messageQueue.put(msg);
        } catch (InterruptedException e) {
            throw new IOException(e);
        }
    }
}

发送消息给所有用户

首先封装一个发送 SSE 格式消息的方法。

/**
 * 发送 SSE 格式的消息到客户端
 *
 * @param writer  输出流,可以向客户端写字符
 * @param message 发送的消息
 */
private void sendMessage(PrintWriter writer, Message message) {
    writer.print("id: ");
    writer.println(message.getId());
    writer.print("data: ");
    writer.println(message.getMessage());
    writer.println();
    writer.flush();
}

然后重载 doGet 方法,因为用户会通过 HTTP GET 方法监听 SSE 事件。

  • 根据是否设置 Last-Event-ID 标头判断用户是否第一次加入聊天室
    • 第一次加入聊天室就构造一条欢迎消息
    • 否则,从 messageStore 中取出一条消息,发送给用户
  • 保存请求的异步操作上下文(即客户端连接)
    • 如果出现异常(掉线、超时、关闭),移除这次连接
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    // 如果用户从主页跳转过来,跳到 /chat.jsp
    if (request.getAttribute("index") != null) {
        request.setAttribute("message", messageStore);
        request.getRequestDispatcher("/chat.jsp").forward(request, response);
        return;
    }

    // 如果客户端在监听 SSE 请求
    if ("text/event-stream".equals(request.getHeader("Accept"))) {

        // 异步请求(Tomcat 特定属性)
        request.setAttribute("org.apache.catalina.ASYNC_SUPPORTED", true);

        // 设置 Header
        response.setContentType("text/event-stream");
        response.setHeader("Cache-Control", "no-cache");
        response.setHeader("Connection", "keep-alive");
        response.setCharacterEncoding("UTF-8");

        // 解析 Last-Event-ID
        String lastMessageId = request.getHeader("Last-Event-ID");
        if (lastMessageId != null && !lastMessageId.trim().isEmpty()) {
            long lastId = 0;
            try {
                lastId = Long.parseLong(lastMessageId);
            } catch (NumberFormatException e) {
                // 因为 lastId 有默认值,不执行任何操作
            }
            if (lastId > 0) {
                // 发送还没发送的所有消息
                for (Message message : messageStore) {
                    if (message.getId() > lastId) {
                        sendMessage(response.getWriter(), message);
                    }
                }
            }
        } else {
            long lastId = 0;
            try {
                lastId = messageStore.get(messageStore.size() - 1).getId();
            } catch (Exception ignored) {
                // 不执行任何操作
            }
            if (lastId > 0) {
                // 使用 lastId 发送消息
                // 如果浏览器连接失败,1s 后重试
                response.getWriter().println("retry:1000\n");
                Message message = new Message(lastId, "欢迎来到聊天室,输入消息,并按下回车键发送");
                sendMessage(response.getWriter(), message);
            }
        }

        // 生成一些唯一的标志符用来保存 context
        final String id = UUID.randomUUID().toString();

        // 启动异步 context 并且添加监听器移除 context
        AsyncContext asyncContext = request.startAsync();
        asyncContext.addListener(new AsyncListener() {
            @Override
            public void onComplete(AsyncEvent event) throws IOException {
                asyncContexts.remove(id);
            }

            @Override
            public void onTimeout(AsyncEvent event) throws IOException {
                asyncContexts.remove(id);
            }

            @Override
            public void onError(AsyncEvent event) throws IOException {
                asyncContexts.remove(id);
            }

            @Override
            public void onStartAsync(AsyncEvent event) throws IOException {
                // 不执行任何操作
            }
        });

        // 添加 context 到 map
        asyncContexts.put(id, asyncContext);
    }
}

关闭服务器

服务器关闭时,终止线程,回收资源。

/**
 * 停止线程,回收资源
 */
@Override
public void destroy() {
    running = false;
    asyncContexts.clear();
    messageQueue.clear();
    messageStore.clear();
}

浏览器:使用 JavaScript 实现 SSE

页面主要结构

<% // 打印消息列表 - 使用 JSTL 更佳 List messages = (List) request.getAttribute("messages"); // 检查 message 非空,不然编译报错 if (messages != null) { for (Message msg : messages) { %> <%= msg.getMessage() %>
<% } } %>

在上面的输入框中输入消息,按下回车键发送消息。

订阅 SSE

JavaScript 使用 EventSource 处理 SSE。

  • 构造 EventSource 对象
  • 注册 onmessage 事件监听器
// 检测浏览器对 EventSource 的支持
if (!!window.EventSource) {
    // 监听 SSE 源
    var source = new EventSource('/chat');

    // 收到服务器的消息
    source.onmessage = function (e) {
        var el = document.getElementById("chat");
        el.innerHTML += e.data + "
"; // 向上滚动一行 el.scrollTop += 50; }; } else { alert("你的浏览器不支持 EventSource!"); }

因为 EventSource 的浏览器兼容性问题,需要先进行检测浏览器是否支持。

初始化页面

页面加载完成后,初始化环境。

  • 滚动至消息栏的底部,显示最新的消息
  • 聚焦输入框,方便用户输入
// 滚动聊天框,聚焦输入框
window.onload = function () {
    // 滚动 100 行消息(有的话)
    document.getElementById("chat").scrollTop += 50 * 100;

    // 聚焦输入框
    document.getElementById("msg").focus();
};

发送消息

定义发送消息给服务器的方法 sendMsg

  • 接受一个表单元素作为参数
  • 验证发送的消息非空
  • 解决 XMLHTTPRequest 的兼容性问题
  • 注册获取到服务器数据的事件监听器 onreadystatechange
// 发送消息给服务器
function sendMsg(form) {

    if (form.msg.value.trim() === "") {
        alert("消息为空!");
    }

    // 初始化 XHR 对象
    let http = false;
    if (typeof ActiveXObject !== "undefined") {
        try {
            http = new ActiveXObject("Msxml2.XMLHTTP");
        } catch (ex) {
            try {
                http = new ActiveXObject("Microsoft.XMLHTTP");
            } catch (ex2) {
                http = false;
            }
        }
    } else if (window.XMLHttpRequest) {
        try {
            http = new XMLHttpRequest();
        } catch (ex) {
            http = false;
        }
    }

    // 浏览器不支持 XHR
    if (!http) {
        alert("无法连接服务器!");
        return;
    }

    // 准备数据
    let parameters = "msg=" + encodeURIComponent(form.msg.value.trim());

    http.onreadystatechange = () => {
        if (http.readyState === 4 && http.status === 200) {
            if (typeof http.responseText !== "undefined") {
                let result = http.responseText;
                form.msg.value = "";
            }
        }
    };

    http.open("POST", form.action, true);
    http.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
    http.send(parameters);

    return false;
}    

你可能感兴趣的:(Server-Sent Events)