Dart: 串联多个数据流

想把多个文件合并成一个文件,每个文件都很巨大,目标文件当然更加巨大。自然而然必须用流的方式,实现起来易如反掌:

dart 复制代码
Future<void> merge(Iterable<String> files, String target) async {
  final streams = files.map((f) => File(f).openRead());
  final ws = File(target).openWrite(mode: FileMode.append);
  for (final s in streams) {
    await ws.addStream(s);
  }
  await ws.close();
}

这里有一个小问题:await ws.addStream(s)为什么不能写成await s.pipe(ws);

方案

实际情况是复杂的,原始文件流可能是一种格式,合并的最终文件可能是另一种格式,比如想把多个分散的txt文件合并生成一个巨大的docx文件。仔细想想也不是个难事:把合并后的原始格式的文件作为一个临时文件,再将这个合并文件通过流的transformer转成目标文件不就可以了么?假定已经有一个txt2docx的流转换器,于是有:

dart 复制代码
Future<void> merge(Iterable<String> files, String target) async {
  final streams = files.map((f) => File(f).openRead());
  final tmp = 'tmp';
  final ws = File(tmp).openWrite(mode: FileMode.append);
  for (final s in streams) {
    await ws.addStream(s);
  }
  await ws.close();
  final rs = File(tmp).openRead();
  await rs.transform(txt2docx).pipe(File(target).openWrite());
}

到这里,哪怕是针对大文件的情况,这种方式也可以覆盖95%的范围了,相比内存,一个磁盘上的临时文件的开销可以忽略不计。

然而了解流的本质就会明白,流不过是一小片内存缓存,通过不断的读入和移除数据把巨量的数据一点点的蚕食完而已。那么这里的这个临时文件显然有点多余:其实我们只需构建一个流,把读入的流按顺序首尾串联起来,然后再直接通过流的transformer转成目标文件。

如此一来就可以省去一次写一次读,我们知道应用运行过程中最耗时的操作其实就是IO,这样一个超大文件的读写,积累起来的耗时是相当可观的。

Dart中构建流的方式只有StreamController,改起来也非常简单,几乎只是把临时文件替换成StreamController

dart 复制代码
Future<void> merge(Iterable<String> files, String target) async {
  final streams = files.map((f) => File(f).openRead());
  final ws = StreamController<List<int>>();
  for (final s in streams) {
    await ws.addStream(s);
  }
  await ws.close();
  final rs = ws.stream;
  await rs.transform(txt2docx).pipe(File(target).openWrite());
}

轻松中再着几分舒爽,舒爽中带着几分优雅~只是换了对象,结构和接口都没有变,含义清晰,操作统一,流的实现真是天才的设计!

验证

然而实测之后让我大吃一惊:程序运行到addStream之后就没有输出了,并且最终文件也没有生成,让人匪夷所思!

可是用临时文件的方式却是没有问题的,看来问题出在StreamController上,反复调试和对比,断点加日志,似乎明白是时序的问题:addStream本质是向StreamController中的sink添加数据,如果等待这个操作结束后,那数据输出流已经无效了,但是如果把消费流数据的操作提前,结果也是错的

dart 复制代码
  final ws = StreamController();
  final rs = ws.stream;
  await rs.transform(txt2docx).pipe(File(target).openWrite());

  for (final s in streams) {
    await ws.addStream(s);
  }
  await ws.close();

此时ws.stream中数据没有数据,因为代码卡在await rs.pipe()这里,根本没有运行到addStream,目标文件虽然生成但大小永远是0。

所以同一个流的读写不能互相串联 ,不能等待写完之后去读,也不能等待读完之后再写。既然读写数据的操作必须都运行到,那让读写这两个操作并行不就行了?两个异步操作并行等待当然用Future.wait()了:

dart 复制代码
Future<void> merge(Iterable<String> files, String target) async {
  final streams = files.map((f) => File(f).openRead());
  final ws = StreamController<List<int>>();
  Future<void> writeInto() async {
    for (final s in streams) {
      await ws.addStream(s);
    }
    await ws.close();
  }
  final rs = ws.stream;
  await Future.wait([
    writeInto(),
    rs.transform(txt2docx).pipe(File(target).openWrite()),
  ]);
}

优化

再次测试,果然没有问题了。然而writeInto这个内部方法有点别扭,既然writeInto是针对StreamController操作,那当然是写成一个扩展方法;另外合并流的操作应当单独设计一个方法:

dart 复制代码
extension _StreamCtrlExt<T> on StreamController<T> {
  Future<void> addAll(Iterable<Stream<T>> streams) async {
    for (final s in streams) {
      await addStream(s);
    }
    await close();
  }
}

/// 外部方法
(Stream<T>, Future<void>) concat<T>(Iterable<Stream<T>> streams) {
  final ctrl = StreamController<T>();
  return (ctrl.stream, ctrl.addAll(streams));
}


Future<void> merge(Iterable<String> files, String target) async {
  final streams = files.map((f) => File(f).openRead());
  final (rs, sending) = concat(streams);
  await Future.wait([
    sending,
    rs.transform(txt2docx).pipe(File(target).openWrite()),
  ]);
}

我们屏蔽StreamController,但需要把只读流StreamController.stream传递给外部,外部根据自身的上下文去使用只读流,但需要把代表只写操作的sending一并传递给外部,这样去并行等待。

这样的方法使用起来非常别扭,自然而然,再更改一下把Future.wait屏蔽起来:

dart 复制代码
/// 外部方法
Future<void> concat<T>(Iterable<Stream<T>> streams,
    Future<void> Function(Stream<T> merged) func) async {
  final ctrl = StreamController<T>();
  await Future.wait([
    ctrl.addAll(streams),
    func.call(ctrl.stream),
  ]);
}

Future<void> merge(Iterable<String> files, String target) async {
  final streams = files.map((f) => File(f).openRead());
  await concat(streams, (rs) => rs.transform(txt2docx).pipe(File(target).openWrite()));
}

看起来似乎简洁一些了,但这个Future.wait又显得非常别扭。Dart的异步任务只要加入到消息循环中,会在下一个消息循环中执行,我们持有这个Future对象实际上没有任何作用,反而让程序显得累赘和啰嗦。我们只需要等待其中一个操作,另一个操作我们并不关心结果,它会自动异步执行,于是改成:

dart 复制代码
/// 外部方法
Stream<T> concat<T>(Iterable<Stream<T>> streams) {
  final ctrl = StreamController<T>();
  ctrl.addAll(streams);
  return ctrl.stream;
}

Future<void> merge(Iterable<String> files, String target) async {
  final streams = files.map((f) => File(f).openRead());
  final merged = concat(streams);
  await merged.transform(txt2docx).pipe(File(target).openWrite());
}

这样一下清爽了许多!实际验证也是没有问题的,仔细观察一下,其实新的程序和最开始的相比,本质上只是去掉了ctrl.addAll(streams);之前的await

既然对同一个流的读写不能依赖,而最新的写法是只等待"写",那是不是可以改成只等待"读"?于是再改成这样:

dart 复制代码
Future<void> merge(Iterable<String> files, String target) async {
  final streams = files.map((f) => File(f).openRead());
  final ctrl = StreamController<List<int>>();
  ctrl.stream.pipe(File(target).openWrite());
  await ctrl.addAll(streams);
}

经过验证也是没有问题的。也就是说要等待"读",必须不能等待"写",但后一种写法无法屏蔽StreamController,串联流的意图也不是很明显。同样,之前那部分错误代码 只需要去掉第一个await就是对的了:

dart 复制代码
  final ws = StreamController();
  final rs = ws.stream;
  rs.transform(txt2docx).pipe(File(target).openWrite());

  for (final s in streams) {
    await ws.addStream(s);
  }
  await ws.close();

之所以绕了一大圈,是因为最开始追加文件的方式只用到了一个只写流final ws = File(target).openWrite(mode: FileMode.append),并没有从流中读数据的操作,而且只有一个异步操作(把追加所有流的操作当作一个大的异步)await addStream(s)。而我们改成StreamCtroller之后,实际多了一步对只读流的操作,而这个只读流偏偏又是用来写入另一个文件的await merged.transform(txt2docx).pipe(File(target).openWrite()),而我们用await的时候只能等待其一。代码虽然一样,但角色和作用其实是不同的。

针对串联流的操作,不仅限于Dart语言,其它语言涉及到流的操作其实也会遇到类似的问题。

相关推荐
红尘散仙5 小时前
我把终端小说阅读器接上了 AI Agent:TRNovel 现在能用 skill 生成书源了
人工智能·后端·rust
卷毛的技术笔记7 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·人工智能·后端·python·spring·ai编程
_codemonster7 小时前
30分钟快速搭建 Spring Cloud Alibaba 微服务实战(一)
微服务·架构·毕业设计·课程设计
会编程的土豆7 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
Cosolar7 小时前
从零写一个 Attention Is All You Need
人工智能·面试·架构
喵个咪7 小时前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
basketball6168 小时前
Go 语言从入门到进阶:4. 数组和MAP使用方法总结
开发语言·后端·golang
qq_2518364578 小时前
SpringBoot+Vue 共享电池柜管理系统 完整实现 前后端分离项目实战 完整代码
vue.js·spring boot·后端
zhangxingchao8 小时前
AI 大模型核心六:量化、Workflow 与 Agent、多轮 RAG
前端·人工智能·后端
qcx239 小时前
【系统学AI】09 Multi-Agent架构(2026版):从学术理论到工业级实践
java·人工智能·架构·multi-agent·claude agent