Long-Polling, WebSocket, SSE 通信方案实践

2,031 阅读6分钟

大多数人都知道 HTTP1.0 不支持长连接,知道 HTTP1.1 支持长连接,这是业界的一个常识。 这样的描述导致了一些不做网络底层开发的开发者都下意识的认为 HTTP1.1 是一个可以建立长连接的的协议。 长连接是存在于网络层的一种连接状态,而实现它则需要在传输层进行开发,因为它是基于对真实数据的收发,需要在底层进行管控。

所谓 HTTP1.1 及以上支持长连接,并不是 HTTP1.1 可以建立长连接,而是它支持以请求头的方式进行长连接发起(并且要求客户端与服务端都要具备 ‘Keep-Alive: true’ )。

  • 短连接: 所谓短连接,及连接只保持在数据传输过程,请求发起,连接建立,数据返回,连接关闭。它适用于一些实时数据请求,配合轮询来进行新旧数据的更替。
  • 长连接: 长连接便是在连接发起后,在请求关闭连接前客户端与服务端都保持连接,实质是保持这个通信管道,之后便可以对其进行复用。 它适用于涉及消息推送,请求频繁的场景(直播,流媒体)。连接建立后,在该连接下的所有请求都可以重用这个长连接管道,避免了频繁了连接请求,提升了效率。
  • 短轮询: 所谓轮询,即是在一个循环周期内不断发起请求来得到数据的机制。只要有请求的的地方,都可以实现轮询,譬如各种事件驱动模型。它的长短是在于某次请求的返回周期。短轮询指的是在循环周期内,不断发起请求,每一次请求都立即返回结果,根据新旧数据对比决定是否使用这个结果。
  • 长轮询: 而长轮询及是在请求的过程中,若是服务器端数据并没有更新,那么则将这个连接挂起,直到服务器推送新的数据,再返回,然后再进入循环周期。长短轮询的理想实现都应当基于长连接,否则若是循环周期太短,那么服务器的荷载会相当重。

1. Polling

Polling 技术被大多数 Ajax 应用使用,基本原理:客户端不停轮询服务端获取数据,如果没有数据也会返回一个空响应。

  • 客户端打开一个链接,使用 http 协议请求服务器端数据;
  • 客户端周期性的发送请求获取数据;
  • 服务器端响应请求,并返回一个响应;
  • 客户端周期性重复以上三步;

轮询的问题在于客户端需要不停的请求服务端,导致一个结果就是很多请求的响应都是空的,导致大量 http 的无用开销。

12519106-b4256e0f1381826d.webp

2. Long-Polling

这是传统轮询技术的一种变体,允许服务器推送数据可用时向客户提供信息。使用长轮询,客户端与正常轮询一样从服务器请求信息,但使用期望服务器可能不会立即响应。这就是为什么这种技术有时被称为“挂起 GET”。

  • 如果服务端没有可用数据,服务端就挂起该请求直到有可用数据;
  • 一旦服务器端游可用数据,响应发送给客户端。客户端立即重新连接服务端,等待下次响应。

Long-Polling 的生命周期如下:

  • 客户端发起一个 http 请求等待响应;
  • 服务端挂起请求直到有可用数据或者链接时间超时;
  • 当服务端有可用数据后发送数据给客户端;
  • 客户端通常会立即发送一个新的长轮询请求接收响应或在暂停后允许可接受的延迟期;
  • 客户端发现长链接超时后发起重新连接。

12519106-a09cdf210d60292e.webp

下边我们自己动手来实践一下:

我们先建立一个父级项目,方便我们后续操作

1660294595032.jpg

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.hupun</groupId>
    <artifactId>WebMessage</artifactId>
    <packaging>pom</packaging>
    <version>0.0.1-SNAPSHOT</version>
    
    <name>WebMessage</name>
    <description>Demo project for Spring Boot</description>

    <modules>
        <module>demo-longpolling</module>
        <module>demo-serversent</module>
        <module>demo-websocket</module>
    </modules>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.2</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

接下来我们搭建一个 spring-web 服务:

1660294945108.jpg

<?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>WebMessage</artifactId>
        <groupId>com.hupun</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>demo-longpolling</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.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

</project>

LongPollingApplication.java

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class LongPollingApplication {

    public static void main(String[] args) {
        SpringApplication.run(LongPollingApplication.class, args);
    }

}

LongPollingServer.java

import lombok.extern.slf4j.Slf4j;
import org.springframework.util.ConcurrentReferenceHashMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;

import java.util.Map;

@Slf4j
@RestController
public class LongPollingServer {

    // 存放所有请求的返回值
    private static final Map<String, DeferredResult<String>>
            resultMap = new ConcurrentReferenceHashMap<>();

    /**
     * 请求连接
     *
     * @param id
     * @return
     */
    @GetMapping("/longPolling")
    public DeferredResult<String> longPolling(String id) {
        // 超时时间设置为300秒
        DeferredResult<String> deferredResult = new DeferredResult<>(300 * 1000L);
        resultMap.put(id, deferredResult);
        log.info("收到" + resultMap.size() + "个请求未处理...");
        deferredResult.onCompletion(() -> {
            resultMap.remove(id);
            log.info("还剩" + resultMap.size() + "个请求未响应...");
        });
        return deferredResult;
    }

    /**
     * 模拟服务端处理完成
     */
    @GetMapping("/returnValue")
    public void returnValue() {
        for (String key : resultMap.keySet()) {
            resultMap.get(key).setResult("id: " + key + " is ok");
        }
    }

}

启动项目,我们准备两个浏览器,分别请求:

http://localhost:8080/longPolling?id=001
http://localhost:8080/longPolling?id=002

1660296015324.jpg

1660296086789.jpg

接下来我们通过请求来模拟服务端处理完毕:

http://localhost:8080/returnValue

1660296194180.jpg

1660296244450.jpg

3. WebSocket

WebSocket 是在 TCP 之上建立了一个全双工通信通道,客户端跟服务器端可以在任何时刻发起通信。客户端通过 websocket 握手建立连接,一旦成功建立连接,客户端跟服务端可以在任何时候双向通信。 Websocket 协议使客户端与服务器端以低开销,近乎实时的方式通信。这可以通过提供标准化的服务器在不被客户端询问的情况下向浏览器发送内容的方式并允许在保持连接的同时来回传递消息。

12519106-82afa3d422e3e994.webp

下边我们自己动手来实践一下:

接下来我们搭建一个 spring-websocket 服务:

1660296596971.jpg

<?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>WebMessage</artifactId>
        <groupId>com.hupun</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>demo-websocket</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-websocket</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.76</version>
        </dependency>
    </dependencies>

</project>

WebSocketApplication.java

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class WebSocketApplication {

    public static void main(String[] args) {
        SpringApplication.run(WebSocketApplication.class, args);
    }

}

WebSocketConfig.java

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

}

WebSocketServer.java

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Component
@ServerEndpoint(value = "/websocket")
public class WebSocketServer {

    // 存放所有在线的客户端
    private static final Map<String, Session> clients = new ConcurrentHashMap<>();

    @OnOpen
    public void onOpen(Session session) {
        clients.put(session.getId(), session);
        log.info("连接加入: {}, 当前在线人数为: {}", session.getId(), clients.size());
    }

    @OnClose
    public void onClose(Session session) {
        clients.remove(session.getId());
        log.info("连接关闭: {}, 当前在线人数为: {}", session.getId(), clients.size());
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        log.info("收到消息: {}, 客户端ID: {}", message, session.getId());
        sendMessage(session.getId(), "服务端收到消息: " + message);
    }

    @OnError
    public void onError(Session session, Throwable error) {
        log.error("发生错误" + (null == session ? "" : ", 客户端ID: " + session.getId()));
        error.printStackTrace();
    }

    /**
     * 发送消息给指定客户端
     */
    public static void sendMessage(String sessionId, String message) {
        try {
            Session session = clients.get(sessionId);
            if (session != null) {
                session.getBasicRemote().sendText(message);
                log.info("发送消息: {}, 客户端ID: {}", message, sessionId);
            }
        } catch (Exception e) {
            log.error("发送消息失败: {}, 客户端ID: {}", message, sessionId, e);
        }
    }

    /**
     * 发送消息给全部客户端
     */
    public static void sendMessageToAll(String message) {
        clients.forEach((sessionId, client) -> sendMessage(sessionId, message));
    }

}

我们在 idea 里边安装一个 web-socket 插件,来测试下:

1660297034321.jpg

输出地址,我们能发现成功连接:

ws://localhost:8080/websocket

1660297184944.jpg

1660297230950.jpg

接下来我们来发送下消息报文,测试下通信结果:

1660297498104.jpg

1660297533328.jpg

4. Server-Sent Events

SSE 是一种通过 HTTP 为 Web 应用程序提供与从服务器到客户端的事件流的异步通信的技术。服务器可以向客户端发送非定向消息/事件,并且可以异步更新客户端。几乎所有浏览器都支持 SSE,除了 Internet Explorer。服务器发送事件 (SSE) 使服务器能够将消息从服务器发送到客户端,而无需任何轮询或长轮询。

12519106-be6d8670d4c12541.webp

下边我们自己动手来实践一下:

接下来我们搭建一个 spring-web 服务:

1660297951690.jpg

<?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>WebMessage</artifactId>
        <groupId>com.hupun</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>demo-serversent</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.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

</project>

ServerSentApplication.java

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ServerSentApplication {

    public static void main(String[] args) {
        SpringApplication.run(ServerSentApplication.class, args);
    }

}

ServerSentServer.java

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.util.ConcurrentReferenceHashMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.util.Map;

@Slf4j
@RestController
public class ServerSentServer {

    // 存放所有请求的发送器
    private static final Map<String, SseEmitter> subscribeMap = new ConcurrentReferenceHashMap<>();

    /**
     * 请求连接
     *
     * @param id
     * @return
     */
    @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter subscribe(String id) {
        // 超时时间设置为300秒
        SseEmitter sseEmitter = new SseEmitter(300 * 1000L);
        sseEmitter.onTimeout(() -> log.info("连接已超时..."));
        sseEmitter.onCompletion(() -> log.info("连接已结束..."));
        subscribeMap.put(id, sseEmitter);
        return sseEmitter;
    }

    /**
     * 模拟服务端发送消息
     *
     * @param id
     * @param message
     */
    @GetMapping("/send")
    public String send(String id, String message) {
        try {
            subscribeMap.get(id).send(message);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "id: " + id + "message: " + message + " is ok";
    }

}

启动项目,我们准备两个浏览器,其中一个请求:

http://localhost:8080/subscribe?id=001

1660298253295.jpg

打开另一个浏览器,连续输入信息:

http://localhost:8080/send?id=001&message=Hello
http://localhost:8080/send?id=001&message=This is Jackey
http://localhost:8080/send?id=001&message=Thank you

1660298462755.jpg

等到连接超时,我们将会看到日志:

1660298560563.jpg