design\project\Vertx(六)

253 阅读18分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 4 天

Vertx 官方 DEMO 示栗(四)

Vertx 快速上手指北(四)

第四章: Vertx 中的 EventBus 再探 —— 异步数据和事件流

本章大纲:

  1. 事件总线 EventBus 深入理解
    1. 什么是 stream 模型,及 Vertx 中的实现
      1. 什么是 stream 中的反向压力,及 Vertx 中的实现
      2. 什么是 steam 中的限流,及 Vertx 中的实现

个人总结:

  1. 本章概要:流(stream)处理
  2. 本章介绍 基于 Vertx EventBus 的 “流” 处理。同样,由很多栗子组成,😋
    1. 对比 JDK 和 Vertx 的 “流” API
    2. 一种使用 Vertx 写文件的栗子
    3. 一种使用 Vertx 读文件的栗子
      1. 一种流式数据的序列化方案
      2. RecordParser 的使用小技巧
    4. 使用 Fetch 模式读文件的栗子
    5. 一个 “音乐盒” 的流媒体应用栗子

JDK 读文件

  • 大多数事件需要流处理,而不是作为孤立事件来处理。处理 HTTP 请求 body 就是一个很好的例子,因为需要组装几个不同大小的缓冲区来重组完整的 body payload。

  • 由于响应式应用程序处理非阻塞I/O,高效和正确的流处理是关键

/**
 * 我们将数据读取到缓冲区
 *    然后立即将缓冲区内容写入标准控制台
 *    然后再回收缓冲区进行下一次读取。
 */
public class JdkStreams {

  public static void main(String[] args) {
    File file = new File("build.gradle.kts");
    byte[] buffer = new byte[1024];

    // 确保 close() 方法被调用
    try (FileInputStream in = new FileInputStream(file)) {
      int count = in.read(buffer);
      while (count != -1) {
        System.out.println(new String(buffer, 0, count));
        count = in.read(buffer);
      }
    } catch (IOException e) {
      e.printStackTrace();
    } finally {
      System.out.println("\n--- DONE");
    }
  }
}

Vertx 读文件 VS JDK 读文件

  • VertX 提供了 stream 的高层抽象,(含文件、网络套接字等)。读流是从某种 eventBus 中取数据,而写流是将事件发送到目的地。例如,HTTP 请求是读流,而 HTTP 响应是写流。
public class VertxStreams {

  public static void main(String[] args) {
    Vertx vertx = Vertx.vertx();

    // 使用只读模式打开
    OpenOptions opts = new OpenOptions().setRead(true);

    // 异步打开文件
    vertx.fileSystem().open("build.gradle.kts", opts, ar -> {
      if (ar.succeeded()) {

        // 异步 file 的 API
        AsyncFile file = ar.result();

        // 读取 file 流时为不同类型的事件定义了处理程序(callback,异常处理,读取结束)
        // 使用 callback 处理 buffer 数据
        file.handler(System.out::println)
          // 异常处理
          .exceptionHandler(Throwable::printStackTrace)
          // 读取结束
          .endHandler(done -> {
            System.out.println("\n--- DONE");
            vertx.close();
          });
      } else {
        ar.cause().printStackTrace();
      }
    });
  }
}

使用 Vertx 写文件的栗子

/**
 * 使用 RecordParser 粘包工具,解析更为复杂的流
 *    RecordParser 粘包工具 简化了我们的工作
 *    我们以 key/value 数据库 存储为例,其中每个 key 和 value 都是一个字符串
 *      1 -> {foo}
 *      2 -> {bar, baz}
 *
 *    其中一种序列化方案
 *      Magic header: 由单个字节组成:1、2、3、4,用于标识文件类型
 *      Version:流的格式版本
 *      Name:字符串形式的数据库名称,以换行符结束
 *      Key length:下一个键的长度
 *      Key name:键名字符序列
 *      Value length:下一个键的长度
 *      Value:值的字符序列
 *      {...}:剩余下的 {key, value} 序列
 *
 * 这种格式混合了二进制和文本记录
 *    以一个魔数、一个版本号、一个名称开始,然后是一系列键和值条目
 *    虽然这种格式本身在某些方面存在问题
 *    但它是一个演示复杂解析的好栗子。
 */
public class SampleDatabaseWriter {

  public static void main(String[] args) {
    Vertx vertx = Vertx.vertx();

    /**
     * 首先,让我们编写一个程序
     *  将一个数据库写入一个文件,其中包含两个键/值对
     *  使用 VertX 的文件系统 api 打开一个文件,将数据写入缓冲区,然后持久化到磁盘。
     */
    AsyncFile file = vertx.fileSystem().openBlocking("sample.db",
      new OpenOptions().setWrite(true).setCreate(true));

    Buffer buffer = Buffer.buffer();

    // Magic number
    buffer.appendBytes(new byte[] { 1, 2, 3, 4});

    // Version
    buffer.appendInt(2);

    // DB name
    buffer.appendString("Sample database\n");

    // Entry 1
    String key = "abc";
    String value = "123456-abcdef";
    buffer
      .appendInt(key.length())
      .appendString(key)
      .appendInt(value.length())
      .appendString(value);

    /**
     * 在这个栗子中,我们只有很少的数据
     * 所以我们只使用了一个缓冲区
     * 如果数据量比较多
     *    我们也可以使用一个缓冲区用于头文件
     *    并为每个键/值条目使用新缓冲区
     */
    // Entry 2
    key = "foo@bar";
    value = "Foo Bar Baz";
    buffer
      .appendInt(key.length())
      .appendString(key)
      .appendInt(value.length())
      .appendString(value);

    // 写入文件
    file.end(buffer, ar -> vertx.close());
  }
}

使用 Vertx 读文件的栗子

/**
 * 写文件很容易,但是读文件呢?一个有趣特性是
 * RecordParser 的解析模式可以动态切换
 *    我们可以开始解析固定大小为 5 的缓冲区,然后切换到基于制表符的解析,然后是 12 个字节数据块,以此类推
 *
 * 解析逻辑,可以通过将其拆分为多个方法来更好呈现
 *    解析模式可以动态切换 和 每次切换后处理程序封装成一个方法 可以简单有效的解析复杂流
 */
public class DatabaseReader {

  private static final Logger logger = LoggerFactory.getLogger(DatabaseReader.class);

  public static void main(String[] args) {
    Vertx vertx = Vertx.vertx();

    AsyncFile file = vertx.fileSystem().openBlocking("sample.db",
      new OpenOptions().setRead(true));

    // 首先,我们读取魔数
    RecordParser parser = RecordParser.newFixed(4, file);
    parser.handler(header -> readMagicNumber(header, parser));
    parser.endHandler(v -> vertx.close());
  }

  private static void readMagicNumber(Buffer header, RecordParser parser) {
    logger.info("Magic number: {}:{}:{}:{}", header.getByte(0), header.getByte(1), header.getByte(2), header.getByte(3));
    parser.handler(version -> readVersion(version, parser));
  }

  private static void readVersion(Buffer header, RecordParser parser) {
    logger.info("Version: {}", header.getInt(0));

    // 解析器模式可以动态切换
    parser.delimitedMode("\n");
    parser.handler(name -> readName(name, parser));
  }

  private static void readName(Buffer name, RecordParser parser) {
    logger.info("Name: {}", name.toString());
    parser.fixedSizeMode(4);
    parser.handler(keyLength -> readKey(keyLength, parser));
  }

  private static void readKey(Buffer keyLength, RecordParser parser) {
    parser.fixedSizeMode(keyLength.getInt(0));
    parser.handler(key -> readValue(key.toString(), parser));
  }

  private static void readValue(String key, RecordParser parser) {
    parser.fixedSizeMode(4);
    parser.handler(valueLength -> finishEntry(key, valueLength, parser));
  }

  private static void finishEntry(String key, Buffer valueLength, RecordParser parser) {
    parser.fixedSizeMode(valueLength.getInt(0));
    parser.handler(value -> {
      logger.info("Key: {} / Value: {}", key, value);
      parser.fixedSizeMode(4);
      parser.handler(keyLength -> readKey(keyLength, parser));
    });
  }
}

使用 Fetch 模式读取流

  • 啥是 Fetch 模式?
    • 就是背压拉
      • 啥是被压?
        • 就是流读取时候的反向压力拉,呃…… 先看栗子,栗子
/**
 * fetch 模式一览
 * 在本章前面提到了 fetch 模式与 推送模式(在本博客中,的后文中应该可能会提到)
 *    它的工作原理是暂停流,然后在需要数据时请求获取数据(忽略,我看不见)
 *
 * 我们重写 Jukebox 的计时器读取代码,在这种模式下,read 和 fetch 没有什么区别(额……)
 *    但是在读取 sample.db 的栗子中
 *
 * 如果您需要手动控制读流,那么它是一个非常有用的工具。 fetch 模式是管理反向压力的更好选择。(呃.....)
 */
public class FetchDatabaseReader {

  private static final Logger logger = LoggerFactory.getLogger(FetchDatabaseReader.class);

  public static void main(String[] args) {
    Vertx vertx = Vertx.vertx();


    AsyncFile file = vertx.fileSystem().openBlocking("sample.db",
      new OpenOptions().setRead(true));

    // 我们使用 RecordParser 修饰文件流
    RecordParser parser = RecordParser.newFixed(4, file);
    // 暂停流,流不会推送事件
    parser.pause();
    // 我们 fetch 一个元素(4 位),即一个 buff
    parser.fetch(1);
    parser.handler(header -> readMagicNumber(header, parser));
    parser.endHandler(v -> vertx.close());
  }

  private static void readMagicNumber(Buffer header, RecordParser parser) {
    logger.info("Magic number: {}:{}:{}:{}", header.getByte(0), header.getByte(1), header.getByte(2), header.getByte(3));
    parser.handler(version -> readVersion(version, parser));
    parser.fetch(1);
  }

  private static void readVersion(Buffer header, RecordParser parser) {
    logger.info("Version: {}", header.getInt(0));

    // 解析器模式可以动态切换
    parser.delimitedMode("\n");
    parser.handler(name -> readName(name, parser));
    parser.fetch(1);
  }

  private static void readName(Buffer name, RecordParser parser) {
    logger.info("Name: {}", name.toString());
    parser.fixedSizeMode(4);
    parser.handler(keyLength -> readKey(keyLength, parser));
    parser.fetch(1);
  }

  private static void readKey(Buffer keyLength, RecordParser parser) {
    parser.fixedSizeMode(keyLength.getInt(0));
    parser.handler(key -> readValue(key.toString(), parser));
    parser.fetch(1);
  }

  private static void readValue(String key, RecordParser parser) {
    parser.fixedSizeMode(4);
    parser.handler(valueLength -> finishEntry(key, valueLength, parser));
    parser.fetch(1);
  }

  private static void finishEntry(String key, Buffer valueLength, RecordParser parser) {
    parser.fixedSizeMode(valueLength.getInt(0));
    parser.handler(value -> {
      logger.info("Key: {} / Value: {}", key, value);
      parser.fixedSizeMode(4);
      parser.handler(keyLength -> readKey(keyLength, parser));
      parser.fetch(1);
    });
    parser.fetch(1);
  }
}

下面,一个 “音乐盒” 的流媒体应用栗子

  • 等会,博主,你这思维,有点跳哈。就给了个读写文件的栗子,突然怎么就流媒体了!
  • 什么 “流”?
  • 什么是流的 push 模式和 pull 模式?
  • 反向压力是什么?
  • 你这个 “音乐盒” 它能做什么?
好!好问题!流就是流!

TCP,UDP,HTTP 请求、响应,WebSockets,读文件,SQL 结果集,Kafka 事件,定时任务这些都是流

  • 栗如:TCP 的粘包大家都知道吧!
    • 我们在内存中有个数组,需要一直等几个不同大小的报文来重组完成粘包
    • 我们可以配置输入的最大缓冲区的大小,Netty 我记得默认是 2G 还是 1G 这么大………………
    • 这个就是 “流” 数据处理啊
    • 总感觉你这个有问题,缓冲区爆了咋整?
    • 哎呀!
好!缓冲区报了咋办?反向压力防爆 “流” !
  • push 模式,假设 我们写了一个 TCP 的 consumer
    • 当 producer 速度快于 consumer 能够处理的速度时
    • 由 consumer 向 producer 发出信号的一种机制
    • 在反应式系统中,反向压力用于暂停或降低生产者的速度
    • 以避免 consumer 内存缓冲区中积累未处理的事件,从而可能耗尽资源(缓冲区爆了)
  • 举个栗子
    • 栗子:某手游客户端(超大文件 2G 他喵的每次全量更新)的更新服务器(100万个客户端同时更新)

    • 呃......假设不走 CDN 啊,所有流量都过你的服务器,也没啥 SRC(SRE?😮??)之类的

    • 4.1.png

    • 操作系统有磁盘缓存,从文件系统读取通常是快速和低延迟的。

    • 相比之下,向网络写入要慢得多,而且带宽取决于最弱的网络链接 + 网络延迟。

    • 由于读的速度比写的快得多

    • 如图所示,写缓冲区可能会很快变得非常大。

    • 如果我们有数千个并发连接来下载 客户端,我们可能会在写入缓冲区队列中积累大量缓冲区。

    • JVM 进程内存中可能有几个 G 的 客户端文件 等待通过网络写入。

    • 写队列中的缓冲区越多,进程消耗的内存就越多

    • 这增加了消耗过多内存甚至崩溃的风险

      • 要么是因为进程耗尽了所有可用的物理内存
      • 要么是因为它运行在内存受限的环境(如容器)中
  • 一种解决方案是 反向压力
    • 当 HTTP 响应写队列增长得太大时,它应该能够通知文件读流速度太快
  • 反倒是 BIO 的 api 有一种隐式的反向压力机制,即阻塞执行线程,直到 I/O 操作完成
    • 所以我懂了,为啥说,读写大文件推荐使用 BIO 而不是 NIO
Vertx 提供了反向压力 API

ReadStream 的反向压力管理 API

  • pause()    暂停生产者,反向压力处理

  • resume() 重启生产者

  • fetch(n) 每次读取 n 个,生产者必须处于 pause 状态才能调用 fetch 方法

    • 异步拉取的一种形式。这意味着消费者可以使用 fetch 请求元素,设置自己的速度

WriteStream 的反向压力管理 API

  • setWriteQueueMaxSize(int) 设定高水位

    • 这里设置的是 Vertx 的 写队列中 buff 数量,不是实际字节数
    • 写队列有默认的大小,您一般不需要调整
    • 达到高水位依旧可以写入
  • writeQueueFull 写队列是否满了

  • drainHandler(Handler) 写缓冲区队列重新空闲回调

    • 小于 WriteQueueMaxSize 的一半,被认为是空闲的
    • 这时可以调用 resume 或者 fetch(n) 加快生产

注意,这种反向压力管理策略并不总是能满足你的需要:

  • 在某些情况下,当写队列已满时会删除旧数据。
  • 有时候事件的来源不支持像 VertX 那样暂停。你需要在删除数据和缓存之间做出平衡,即使这可能会导致内存耗尽。
最后,反向压力不能解决所有问题

如何确保所有客户端(含未来加入的客户端)在相同的时间听到(几乎)相同的音乐

听上去很简单啊,服务器一个音符一个音符的发啊,然后所有人不就都听到相同的内容了?
你...... 那..... 下面是中文听力题
下雨....(网络延迟,也有可能是断句)......天........留客....(网络延迟)....天.......留......我......不..(由于服务器一个音符一个音符发而造成得卡顿)....留....
问:客人走了么?
答:这………………
下雨天,留客天,留我不?留
下雨天,留客天,留我?不留

image.png

图片来源于网络,侵删

4.2.png

图:左图:没有限流,不能同步;右图:一句话一句话发,即不卡,又基本同步

这个音乐盒它能做什么?

他能听音乐,完……

    MP3文件有一个包含元数据的头,比如艺术家的名字、类型、比特率等等。随后是几帧包含压缩音频数据的帧,解码器可以将这些帧转换为脉冲编码调制数据,最后再转换成声音。

    MP3解码器有很强的容错性,所以就算从文件中间开始解码,依旧可以获得比特率,并能对齐下一帧地址,开始解码音频。您甚至可以连接多个 MP3 文件,并将它们一起发送播放器。只要这些文件都使用相同的比特率和立体声模式。

    基于这点,当我们设计一个音乐流媒体点唱机时,如果我们的文件是以相同的方式编码的,我们可以简单地将播放列表的每个文件一个接一个地推入,解码器将很好地处理音频。

音乐盒 “栗子”

  • Mian 函数
/**
 *
 */
public class Main {

  public static void main(String[] args) {
    Vertx vertx = Vertx.vertx();
    // Jukebox 提供了主要的音乐流媒体播放 和 HTTP 服务器接口
    vertx.deployVerticle(new Jukebox());
    // NetControl 提供了一个基于文本的 TCP 协议,用于远程控制点唱机应用程序。
    vertx.deployVerticle(new NetControl());
  }
}
  • 基于 HTTP 协议的流媒体播放
/**
 * 在本地存储一些MP3文件 ||Song 1||Song 2||Song 3|| Playlist
 *    客户端可以通过接收服务端的 HTTP stream 收听 Audio stream (HTTP)
 *        假设:所有连接的播放器将收听相同的音频
 *    客户端可以通过 HTTP 下载属于自己的音频 Direct file download (HTTP)
 *    反过来,客户端可以通过简单的基于文本的 TCP 协议来控制播放、暂停和歌曲列表 Command interface (TCP)
 *
 * 在这个栗子中我们将学习
 *    如何处理流式数据的限流
 *    不同的反压力管理策略
 *    以及如何解析流式数据
 *
 * Jukebox 提供了主要的音乐流媒体播放 和 HTTP 服务器接口
 */
public class Jukebox extends AbstractVerticle {

  private final Logger logger = LoggerFactory.getLogger(Jukebox.class);

  @Override
  public void start() {
    logger.info("Start");

    // 配置一些事件总线处理程序,这些处理程序对应于从 TCP 的输入控制命令和操作
    // 我们已经抽象了事件模型,通过总线的命令,我们可以方便的新增命令
    EventBus eventBus = vertx.eventBus();
    eventBus.consumer("jukebox.list", this::list);
    eventBus.consumer("jukebox.schedule", this::schedule);
    eventBus.consumer("jukebox.play", this::play);
    eventBus.consumer("jukebox.pause", this::pause);

    vertx.createHttpServer()
      .requestHandler(this::httpHandler)
      .listen(8080);

    // 计时器定时地从本地的 MP3 文件中读取数据,并写入每个 stream 流。
    // 确保所有现在和未来加入的播放器在几乎相同的时间听相同的音乐,这个计时器是关键
    // streamAudioChunk 负责定期推送新 MP3 数据 ( 100 毫秒是纯粹的经验,请随意调整)。
    vertx.setPeriodic(100, this::streamAudioChunk);
  }

  // --------------------------------------------------------------------------------- //

  /**
   * 播放状态:正在播放,暂停播放
   */
  private enum State {PLAYING, PAUSED}

  /**
   * 播放状态 + 播放列表 共同定义了点唱机服务的的状态
   */
  private State currentMode = State.PAUSED;
  /**
   * 播放状态 + 播放列表 共同定义了点唱机服务的的状态
   *    Queue 保存所有预定曲目(播放列表)
   *    vertx 的单线程模型保障了这些变量的线程安全
   */
  private final Queue<String> playlist = new ArrayDeque<>();

  // --------------------------------------------------------------------------------- //

  /**
   * 列出可用的文件有点复杂
   * @param request
   */
  private void list(Message<?> request) {
    // 我们异步地获得了 tracks 文件夹中所有以 .mp3 结尾的文件
    vertx.fileSystem().readDir("tracks", ".*mp3$", ar -> {
      if (ar.succeeded()) {
        List<String> files = ar.result()
          .stream()
          .map(File::new)
          .map(File::getName)
          .collect(Collectors.toList());
        // 创建 JSON 响应
        JsonObject json = new JsonObject().put("files", new JsonArray(files));
        request.reply(json);
      } else {
        logger.error("readDir failed", ar.cause());
        // 这是一个通过发送失败代码和错误消息的示例。
        request.fail(500, ar.cause().getMessage());
      }
    });
  }

  // --------------------------------------------------------------------------------- //

  /**
   * 这些事件响应程序直接操作播放状态 和 播放列表
   * @param request
   */
  private void play(Message<?> request) {
    logger.info("Play");
    currentMode = State.PLAYING;
  }

  /**
   * 这些事件响应程序直接操作播放状态 和 播放列表
   * @param request
   */
  private void pause(Message<?> request) {
    logger.info("Pause");
    currentMode = State.PAUSED;
  }

  /**
   * 这些事件响应程序直接操作播放状态 和 播放列表
   * @param request
   */
  private void schedule(Message<JsonObject> request) {
    String file = request.body().getString("file");
    logger.info("Scheduling {}", file);
    if (playlist.isEmpty() && currentMode == State.PAUSED) {
      currentMode = State.PLAYING;
    }
    playlist.offer(file);
  }

  // --------------------------------------------------------------------------------- //

  /**
   * 有两种类型 HTTP 请求:
   *    客户端希望直接按名称下载文件
   *    客户端希望加入音频流
   * @param request
   */
  private void httpHandler(HttpServerRequest request) {

    logger.info("{} '{}' {}", request.method(), request.path(), request.remoteAddress());

    if ("/".equals(request.path())) {

      // 加入音频流
      openAudioStream(request);
      return;
    }
    if (request.path().startsWith("/download/")) {
      // 防止从其他目录读取文件的恶意攻击行为 (想象一下有人愿意读取/etc/passwd)
      String sanitizedPath = request.path().substring(10).replaceAll("/", "");

      // 下载音频
      download(sanitizedPath, request);
      return;
    }
    // 当路径无法匹配时,我们响应 404 (未找到)。
    request.response().setStatusCode(404).end();
  }

  // --------------------------------------------------------------------------------- //
  /**
   * 缓存所有当前 response
   */
  private final Set<HttpServerResponse> streamers = new HashSet<>();

  /**
   * 我们需要跟踪所有 streamers 的 HTTP 响应写流
   *    我们使用计时器从本地的 MP3 文件中读取数据,并写入每个 streamers 数据。
   *
   * @param request
   */
  private void openAudioStream(HttpServerRequest request) {
    logger.info("New streamer");
    HttpServerResponse response = request.response()
      // 因为是 stream 类型,所以长度未知
      .putHeader("Content-Type", "audio/mpeg")
      .setChunked(true);

    // 加入 streamers
    streamers.add(response);

    // 当 stream 退出时,从缓存中移除
    response.endHandler(v -> {
      streamers.remove(response);
      logger.info("A streamer left");
    });
  }

  // --------------------------------------------------------------------------------- //

  /**
   * 下载文件
   *    目标是从文件读流直接复制到 HTTP 响应写流
   *    将通过反向压力来避免过度缓冲
   * @param path
   * @param request
   */
  private void download(String path, HttpServerRequest request) {
    String file = "tracks/" + path;

    // 判断 tracks 文件夹下,文件是否存在
    // 除非您是基于网络的文件系统,否则阻塞的情况会很短暂,所以我们没有做内嵌回调处理
    if (!vertx.fileSystem().existsBlocking(file)) {
      request.response().setStatusCode(404).end();
      return;
    }
    OpenOptions opts = new OpenOptions().setRead(true);
    vertx.fileSystem().open(file, opts, ar -> {
      if (ar.succeeded()) {
        downloadFile(ar.result(), request);
      } else {
        logger.error("Read failed", ar.cause());
        request.response().setStatusCode(500).end();
      }
    });
  }

  /**
   * 从文件读流直接复制到 HTTP 响应写流
   * @param file
   * @param request
   */
  private void downloadFile(AsyncFile file, HttpServerRequest request) {
    HttpServerResponse response = request.response();
    response.setStatusCode(200)
      .putHeader("Content-Type", "audio/mpeg")
      .setChunked(true);

    file.handler(buffer -> {
      // 将读取到的文件流直接写入 socket 缓冲区
      response.write(buffer);
      // 反向压力机制,如果 response 的 socket 缓冲区已经满了(写入太快)
      if (response.writeQueueFull()) {
        // 暂停文件流读取(反向压力机制生效)
        file.pause();
        // 当 response 的 socket 缓冲区重新下降到 一半以下,重新开启文件读取流
        response.drainHandler(v -> file.resume());
      }
    });

    // 使用 end 函数 flush response
    file.endHandler(v -> response.end());
  }

  /**
   * 在两个流之间复制数据时,要注意反向压力机制。
   *    很多情况下,我们需要这样做:暂停源且不丢失任何数据
   *        意思就是:vertx 封装了 pipeTo 实现了该共通
   *    因此,下载文件,我们还可以这样写
   * @param file
   * @param request
   */
  private void downloadFilePipe(AsyncFile file, HttpServerRequest request) {
    HttpServerResponse response = request.response();
    response.setStatusCode(200)
      .putHeader("Content-Type", "audio/mpeg")
      .setChunked(true);
    // 将数据从 文件 泵给 response 响应
    // ReadStream 和 WriteStream 流之间复制时,pipe 处理反压力
    //
    file.pipeTo(response);
  }

  // --------------------------------------------------------------------------------- //

  private AsyncFile currentFile;
  private long positionInFile;

  /**
   *
   */
  private void streamAudioChunk(long id) {
    if (currentMode == State.PAUSED) {
      return;
    }
    if (currentFile == null && playlist.isEmpty()) {
      currentMode = State.PAUSED;
      return;
    }
    if (currentFile == null) {
      openNextFile();
    }

    /**
     * 为什么我们每 100 毫秒读取一次数据? 为什么读缓冲区是 4096 字节?
     *    这些值,是根据经验测试出来的,这些值对于我的笔记本电脑上的 320kbps 恒定比特率 MP3 文件很好用
     *    这些值,确保测试中不会出现中断,同时防止播放器缓冲过多数据,让音频流传输在数秒内结束
     * 当运行在别的环境中时,您可以随意修改这些值。
     */
    // 定期的从本地 MP3 文件中读取数据
    // Buffer 不能在 I/O 操作中重用,因此我们每次都 new 一个新的 buffer
    // 每秒读取 10 次,每次读取 4096 字节数据
    currentFile.read(Buffer.buffer(4096), 0, positionInFile, 4096, ar -> {
      if (ar.succeeded()) {
        // 并写入每个 stream 流
        // 这是数据被复制给所有播放器
        processReadBuffer(ar.result());
      } else {
        logger.error("Read failed", ar.cause());
        closeCurrentFile();
      }
    });
  }

  // --------------------------------------------------------------------------------- //

  /**
   * 同样,我们使用了阻塞的方式打开文件
   * 但是打开文件造成的阻塞,影响应该较小
   */
  private void openNextFile() {
    logger.info("Opening {}", playlist.peek());
    OpenOptions opts = new OpenOptions().setRead(true);
    currentFile = vertx.fileSystem()
      .openBlocking("tracks/" + playlist.poll(), opts);
    positionInFile = 0;
  }

  private void closeCurrentFile() {
    logger.info("Closing file");
    positionInFile = 0;
    currentFile.close();
    currentFile = null;
  }

  // --------------------------------------------------------------------------------- //

  /**
   *
   */
  private void processReadBuffer(Buffer buffer) {
    logger.info("Read {} bytes from pos {}", buffer.length(), positionInFile);
    positionInFile += buffer.length();
    // 当文件达到结尾时
    if (buffer.length() == 0) {
      closeCurrentFile();
      return;
    }
    for (HttpServerResponse streamer : streamers) {

      // 此处如果 socket 输出缓冲区满了,就丢弃了这个数据???(简单处理)
      // 对于播放器来说,这将导致音频掉帧
      // 由于服务器上的队列已满,这播放器的延迟或丢包没有什么问题。我们优先确保各个播放器的同步
      if (!streamer.writeQueueFull()) {
        // 此处为什么需要 buffer.copy
        // buffer.copy() 只是 new BufferImpl 在内存中新建了一个 Buffer 的引用
        // 因为 Buffer 中保存了读写指针,为了 response 写 buffer 时不影响读文件的 buffer
        // buffer 不能被重用
        streamer.write(buffer.copy());
      }
    }
  }
}
  • 基于 TCP 协议的,上一首,下一首控制器(好像什么 IPod 也是基于 TCP 控制的哈,博主记不得了)
/**
 * NetControl 提供了一个基于文本的 TCP 协议,用于远程控制点唱机应用程序。
 *    在端口 3000 上暴露一个 TCP 服务器
 *        用于接收文本命令来控制点唱机的播放内容
 *    从 异步数据流 中提取数据是一个常见的需求,VertX 提供了异步 TCP 流数据获取工具
 *
 *    文本协议的 命令是以下形式:
 *    /action [argument]
 *    每个文本行只能有一个命令,因此协议被称为  newline separated
 *    因为缓冲区以块的形式到达,很少对应一行
 *    所以解决方案是在缓冲区到达时将它们连接起来,然后在换行符上再次分割它们,这样每个缓冲区就有一行
 *    Vertx 提供了 RecordParser 通过查找分隔符或处理固定大小的块,进行 Codec
 */
public class NetControl extends AbstractVerticle {

  private final Logger logger = LoggerFactory.getLogger(NetControl.class);

  // --------------------------------------------------------------------------------- //

  @Override
  public void start() {
    logger.info("Start");
    vertx.createNetServer()
      .connectHandler(this::handleClient)
      .listen(3000);
  }

  private void handleClient(NetSocket socket) {
    logger.info("New connection");
    // 按行粘包
    // 解析器的粘包同时对 输入流 和 输出流 生效,因为它充当两个流之间的适配器。
    RecordParser.newDelimited("\n", socket)
      // 得到的 buffer 为粘包后的,每个 buffer 一行数据
      .handler(buffer -> handleBuffer(socket, buffer))
      .endHandler(v -> logger.info("Connection ended"));
  }

  // --------------------------------------------------------------------------------- //

  private void handleBuffer(NetSocket socket, Buffer buffer) {
    // 使用默认字符集解码
    String command = buffer.toString();
    // 简单的使用 case 处理不同的命令
    switch (command) {
      case "/list":
        listCommand(socket);
        break;
      case "/play":
        vertx.eventBus().send("jukebox.play", "");
        break;
      case "/pause":
        vertx.eventBus().send("jukebox.pause", "");
        break;
      default:
        if (command.startsWith("/schedule ")) {
          schedule(command);
        } else {
          socket.write("Unknown command\n");
        }
    }
  }

  // --------------------------------------------------------------------------------- //

  private void schedule(String command) {
    // 切掉前面的 魔数
    String track = command.substring(10);
    JsonObject json = new JsonObject().put("file", track);
    vertx.eventBus().send("jukebox.schedule", json);
  }

  private void listCommand(NetSocket socket) {
    vertx.eventBus().request("jukebox.list", "", reply -> {
      if (reply.succeeded()) {
        JsonObject data = (JsonObject) reply.result().body();
        data.getJsonArray("files")
          // 我们将所有文件名输出给客户端
          .stream().forEach(name -> socket.write(name + "\n"));
      } else {
        logger.error("/list error", reply.cause());
      }
    });
  }

  // --------------------------------------------------------------------------------- //
}