敞开成长之旅!这是我参加「日新计划 2 月更文挑战」的第 29 天,点击查看活动详情

在 HTTP/1.1 及曾经的版别里,客户端、服务端之间的通讯形式只支持一种恳求-呼应形式,这是一种半双工通讯形式。
并且,在这个模型中,服务端是“被动方”,只能呼应用户的恳求,不能主动地推送音讯给客户端。
为了处理这个问题,呈现了支持实时通讯的 WebSocket 协议,以及 HTTP/2、HTTP/3 中的 Stream、Server Push 等特性。
可是,在 HTTP/1.1 版别中,要完成“实时通讯”的作用只能经过轮询(Polling)技能。并且,轮询也只能达到近似“实时通讯”作用。

今日,我将介绍如安在 Spring Boot 中经过轮询完成服务端数据变化后通知客户端。

01-短轮询和长轮询

短轮询比较好理解,指在客户端经过循环的办法每隔一段时刻就恳求一下服务端,是否有数据更新。
短轮询的伪代码相似于:

while(true) {
    // 恳求服务端
    resposne = query(request);
    // 假如服务端有数据回来,则处理;不然,持续恳求服务端   
    if (hasData(response)) {    
        process(response);
    } else {
        // 隔 100ms 再次恳求
        sleep(100);
    }
}

短轮询计划中最大的问题是,频繁的恳求对服务端的压力太大,并且也糟蹋网络带宽资源。
长轮询是对短轮询计划的一种改进,旨在减少对服务器资源的糟蹋。
长轮询在中间件中使用比较常见,例如 Nacos 装备中心,RocketMQ 音讯队列等。

长轮询与短轮询的机制是相似的,客户端的逻辑不变,主要在服务端优化。
在长轮询中,服务端在没有数据更新时并不会当即呼应客户端的恳求,而是会 hold 住一段时刻。
在这段时刻中,假如数据有更新,则当即回来;假如没有更新,超时后客户端会再次发起恳求。

01.1-堵塞长轮询完成办法

一种规范的完成办法是,在服务端直接等候数据更新,等候一段时刻后再呼应恳求。

@RestController
@RequestMapping("/polling")
public class BlockingController {
    @GetMapping("/blocking")
    public ResponseEntity<?> processBlocking(Model model) {
        try {
            // 模仿长时刻等候数据更新
            TimeUnit.SECONDS.sleep(30);
        } catch (InterruptedException ie) { }
        return ResponseEntity.ok("ok");
    }
}

这种办法有一种明显的缺点,Servlet 容器中处理恳求的线程会被堵塞,导致服务端的吞吐量下降,难以应对高并发场景。

01.2-非堵塞长轮询完成办法

对上节中的一种改进是把 Servlet 中处理线程的工作交给其他的线程去做。

@RestController
@RequestMapping("/polling")
public class NonBlockingController {
    private ExecutorService pool = Executors.newFixedThreadPool(5);
    @GetMapping("/nonblocking")
    public DeferredResult<String> processNonblocking(Model model) {
        DeferredResult<String> output = new DeferredResult<>();
        pool.execute(() -> {
            try {
                // 模仿长时刻等候数据更新
                TimeUnit.SECONDS.sleep(30);
                output.setResult("ok");
            } catch (InterruptedException e) {
                output.setErrorResult("Something went wrong with your order!");
            }
        });
        return output;
    }
}

能够看到,在 handler 中,创建了一个使命提交到了线程池里面去履行,Servlet 恳求处理线程当即回来了。

咱们来看下 Spring 的日志.

[nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : GET "/polling/nonblocking", parameters={}
[nio-8080-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to self.samson.example.polling.controller.NonBlockingController#processNonblocking(Model)
[nio-8080-exec-1] o.s.w.c.request.async.WebAsyncManager    : Started async request
[nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Exiting but response remains open for further handling
[pool-1-thread-1] o.s.w.c.request.async.WebAsyncManager    : Async result set, dispatch to /polling/nonblocking
[nio-8080-exec-2] o.s.web.servlet.DispatcherServlet        : "ASYNC" dispatch for GET "/polling/nonblocking", parameters={}
[nio-8080-exec-2] s.w.s.m.m.a.RequestMappingHandlerAdapter : Resume with async result ["ok"]
[nio-8080-exec-2] m.m.a.RequestResponseBodyMethodProcessor : Using 'text/html', given [text/html, application/xhtml+xml, image/avif, image/webp, image/apng, application/xml;q=0.9, application/signed-exchange;v=b3;q=0.7, */*;q=0.8] and supported [text/plain, */*, text/plain, */*, application/json, application/*+json, application/json, application/*+json]
[nio-8080-exec-2] m.m.a.RequestResponseBodyMethodProcessor : Writing ["ok"]
[nio-8080-exec-2] o.s.web.servlet.DispatcherServlet        : Exiting from "ASYNC" dispatch, status 200

第 4 行时,Servlet 恳求处理线程履行完 NonBlockingController#processNonblocking 办法,可是此时 Servlet 容器并没有回复客户端的恳求,直到线程池履行完使命,经过 output.setResult 回来成果,呼应才发送给客户端。
能够看到,这里的 DeferredResult 是完成长轮询机制的要害,这个我将在下一节中介绍。

这种异步呼应特性需求 Servlet 3.0 及以上容器才干支持,并且 Spring 的版别要高于 3.2。
在上面的完成中,恳求的处理过程是在别的一个线程(线程池中)完成的,并且在处理完成后,调用了 DeferredResult#setResult 办法。
在这个过程中,底层 Servlet 容器会保持住与客户端之间的衔接,直到呼应完成,或许恳求超时(默认为 60s)。

02. Spring 中的 DeferredResult

DeferredResult 是 Spring 供给的一个异步恳求处理接口,在 I/O 密布的场景中非常有用。
它支持三种类型的回调:

  1. onCompletion,当异步恳求处理完毕后,会履行这部分代码。
  2. onError,当异步履行遇到问题后,会履行这部分代码。
  3. onTimeout,当异步履行超时后,会履行这部分代码。

接下来,我将经过一个例子来演示下这三类回调的使用场景。

假设我经营了一家面包店,顾客能够经过 /bakery/order/{something} 来点单,点的单都会放到一个订单表中。
面包师会根据订单制造,制造好了经过 /bakery/finish/{order} 来完成订单。
假如顾客比较心急,等候必定时刻后,会撤销订单。
咱们来看下怎么完成:

@RestController
@RequestMapping("/bakery")
public class BakeryController {
    // 存储订单,假设目前因为人手原因,每种产品最多同时接一单
    private Map<String, DeferredResult<String>> orders = new HashMap<>();
    @GetMapping("/order/{something}")
    public DeferredResult<String> order(@PathVariable("something") String something) {
        DeferredResult<String> result = new DeferredResult<>(20 * 1000L);
        result.onCompletion(() -> {
            orders.remove(something);
            System.out.println("顾客,您的" + something + "做好了!");
        });
        result.onTimeout(() -> {
            orders.remove(something);
            System.out.println("做得太慢了,我不要了");
        });
        result.onError((e) -> {
            orders.remove(something);
            System.out.println("出错了,好吧,我换一家");
        });
        orders.put(something, result);
        return result;
    }
    @GetMapping("/finish/{order}")
    public String finish(@PathVariable("order") String order) {
        if (orders.containsKey(order)) {
            DeferredResult<String> result = orders.get(order);
            result.setResult("我完成了一单 " + order);
        }
        return "success";
    }
}

顾客点单后会等候,此时假如面包师能够在顾客耐心消失前做好他的订单,就会通知他取产品(onCompletion);
假如顾客等候的耐心全无,就会撤销订单脱离(onTimeout);
假如制造过程中,遇到问题,顾客会撤销订单(onError)。

03-总结

今日介绍了经过轮询办法向服务端“实时”推送音讯的完成办法。
这实际上是一种伪实时办法。假如要完成真实的实时通讯,需求用到其他的技能,例如 WebSocket、更高版别的 HTTP 协议等。

期望今日的内容能对你有所协助。