携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 4 天
Vertx 官方 DEMO 示栗(四)
Vertx 快速上手指北(四)
第四章: Vertx 中的 EventBus 再探 —— 异步数据和事件流
本章大纲:
- 事件总线 EventBus 深入理解
- 什么是 stream 模型,及 Vertx 中的实现
- 什么是 stream 中的反向压力,及 Vertx 中的实现
- 什么是 steam 中的限流,及 Vertx 中的实现
个人总结:
- 本章概要:流(stream)处理
- 本章介绍 基于 Vertx EventBus 的 “流” 处理。同样,由很多栗子组成,😋
- 对比 JDK 和 Vertx 的 “流” API
- 一种使用 Vertx 写文件的栗子
- 一种使用 Vertx 读文件的栗子
- 一种流式数据的序列化方案
- RecordParser 的使用小技巧
- 使用 Fetch 模式读文件的栗子
- 一个 “音乐盒” 的流媒体应用栗子
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?😮??)之类的
-
-
操作系统有磁盘缓存,从文件系统读取通常是快速和低延迟的。
-
相比之下,向网络写入要慢得多,而且带宽取决于最弱的网络链接 + 网络延迟。
-
由于读的速度比写的快得多
-
如图所示,写缓冲区可能会很快变得非常大。
-
如果我们有数千个并发连接来下载 客户端,我们可能会在写入缓冲区队列中积累大量缓冲区。
-
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 那样暂停。你需要在删除数据和缓存之间做出平衡,即使这可能会导致内存耗尽。
最后,反向压力不能解决所有问题
如何确保所有客户端(含未来加入的客户端)在相同的时间听到(几乎)相同的音乐
听上去很简单啊,服务器一个音符一个音符的发啊,然后所有人不就都听到相同的内容了?
你...... 那..... 下面是中文听力题
下雨....(网络延迟,也有可能是断句)......天........留客....(网络延迟)....天.......留......我......不..(由于服务器一个音符一个音符发而造成得卡顿)....留....
问:客人走了么?
答:这………………
下雨天,留客天,留我不?留
下雨天,留客天,留我?不留图片来源于网络,侵删
图:左图:没有限流,不能同步;右图:一句话一句话发,即不卡,又基本同步
这个音乐盒它能做什么?
他能听音乐,完……
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());
}
});
}
// --------------------------------------------------------------------------------- //
}