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语言,其它语言涉及到流的操作其实也会遇到类似的问题。

相关推荐
用户67570498850225 分钟前
Go 语言中如何操作二维码?
后端
这里有鱼汤26 分钟前
想成为下一个吉姆·西蒙斯,这十种经典K线形态你一定要记住
后端·python
SimonKing31 分钟前
吊打面试官系列:BeanFactory和FactoryBean的区别
java·后端·面试
江湖十年40 分钟前
一行命令统计代码行数
后端·go·命令行
天天摸鱼的java工程师44 分钟前
互联网行业能力解刨:从Java后端八年开发经验看
前端·后端·程序员
DemonAvenger44 分钟前
Go 中 string 与 []byte 的内存处理与转换优化
性能优化·架构·go
brzhang1 小时前
Android 16 卫星连接 API 来了,带你写出「永不失联」的应用
前端·后端·架构
程序员爱钓鱼1 小时前
Go并发模型与模式:context 上下文控制
后端·google·go
AI小智1 小时前
AI提效99.5%!英国政府联手 Gemini,破解城市规划审批困局
后端
风象南1 小时前
SpringBoot的4种抽奖活动实现策略
java·spring boot·后端