Dart: 随机访问文件的异步读写

88 阅读6分钟

Dart中用流来读写文件的确非常方便和优雅,然而流也不是万能的,实际应用中很多情况是不适合用流的,比如需要预先读取文件指定位置后的一部分数据,然后再读取指定位置之前的数据。

zip压缩文件便是这种情况的典型例子,解压过程需要先从文件末尾向前找到中央目录记录块(End of Central Directory Record),即EOCD;然后根据EOCD中的字段读取不同位置的不同数据,它们便是压缩文件中各种子文件的压缩数据。这个时候如果强行用流读取,就需要把流中一段一段的数据全部缓存起来,读取过的数据还不能丢弃,结果就相当于把整个文件读取到内存中了。

这种需要不断滑来滑去读取部分数据的操作必须用到随机访问文件了(Random Access File),很多人都用到过。随机访问文件可以被当作一块巨大的数据连续区域,可以快速读写,但又不实际占用内存,是非常普遍的文件读写方式。

但是现在遇到的情况是:同步地从压缩文件中读取文件信息(ZipFileEntry),但文件数据需要以流的方式读取,对应代码便是:

final class ZipFileEntry {
  final String name;
  final Stream<List<int>> data;
}

final class ZipArchieve {
  final List<ZipFileEntry> entries;
}

读取一个RandomAccessFile获取一个ZipArchieve对象,它有多个文件入口ZipFileEntry;根据需要获取指定ZipFileEntry对象后,再获取它的具体数据,获取数据的方式是异步的、是流方式的,因为文件可能非常巨大,现在上G的压缩文件早已非常常见了。

这种需求也是很合理的,压缩文件中的文件入口信息并不大,但文件本身可能非常巨大,以流的方式异步获取也在情理之中。用了流就可以实现transformer,可以和其它的流操作无缝衔接,否则总是需要一个临时文件中转。

看看Dart中非常著名的archive包,它虽然提供了流的接口,但实际方式其实并非同步,它的底层接口AbstractFileHandle,其实还是以同步的方式读写数据,不那么优雅~给archive提了一个建议,希望彻底改成以流的方式特别是transformer的方式,但作者拒绝了。

这点燃了码畜重造轮子的热情,于是立即动手搞了起来。

zip文件头解析算法是用人工智能帮助生成的,人工智能带来了极大的便利,但也不能全部相信,有几处细节是完全错误的,结果调试和验证花了不少时间,还不如自己从头实现来得清晰,真是让人痛苦。

在读取文件内容处,改成了如下形式:

RandomeAccessFile file;
...
final fileDataStream = file
  ._readStream(dataStartOffset, fileDataLen)
  .transform(_Decompressor(compressed));

这里针对RandomeAccessFile实现了一个便利方法,:

extension RamFileExt on RandomAccessFile {

  Stream<List<int>> _readStream(int position, int length) async* {
    const bufLength = 1 << 20;
    final end = position + length;
    var pos = position;
    while (pos + bufLength < end) {
      setPositionSync(pos);
      yield await read(bufLength);
      pos += bufLength;
    }
    final len = end - pos;
    if (len > 0) {
      setPositionSync(pos);
      yield await read(len);
    }
  }
}

_Decompressor是利用dart库中已有的ZLibDecoder实现的解压转换器,也就是说压缩文件中压缩后的文件数据以流的方式传到_Decompressor后,出来的就是解压后的完整文件数据流,绝妙极了!_readStream所要做的只不过是异步地以流的方式读取指定位置指定长度的数据罢了。

就在我得意洋洋地运行时,突然报出一个FileIOException: an async file operation is pending的错误,真如当头一棒。

既然报异步操作的问题,那便在_readStream方法中把setPositionSync全部改成setPosition,然而竟然也不好使!

最后无奈看了一下RandomeAccessFile的文档,这时发现setPosition的返回值是一个RandomeAccessFile的Future对象,这说明后续操作的对象应当是这个新对象,于是改了一下,果然大功告成!

  Stream<List<int>> _readStream(RandomAccessFile file) async {
    const bufLength = 1 << 20;
    final end = position + length;
    var pos = position;
    var f = file;
    while (pos + bufLength < end) {
      f = await f.setPosition(pos);
      yield await f.read(bufLength);
      pos += bufLength;
    }
    final len = end - pos;
    if (len > 0) {
      f = await f.setPosition(pos);
      yield await f.read(len);
    }
    return f;
  }

既然改成异步读取,那就应当彻底是异步的,之前想到因为当前文件是同一个文件,所以用了setPositionSync,但read之后,可能文件对象已经发生了变化,不应该再在之前的文件再次setPositionSync了。

就在我得意洋洋换了一个压缩文件再次测试时,居然又报出pending的异常来,让人骇然。对比了一下发现虽然第一个压缩文件包含了2个文件,但有一个是空文件,所以没有进行实际的异步读取操作。把第一个压缩文件中的空文件换成非空的,果然也报出一模一样的错误,让人郁闷。

后来想到之前这个操作是在持有了新的随机文件对象后成功的,那以此类推,当解压压缩文件中第二个文件时,异步读取的文件对象也应当是第一个文件异步后的文件对象,也就是说第一个文件最后一次setPosition之后的文件对象需要传递给第二个文件去读取。

写这一段的验证代码让人难受,当时甚至用了函数对象把异步读取的操作包裹了起来,庆幸的是,结果是对的。经过反复尝试和修改,最终改成了如下的形式:

RandomeAccessFile file;
// 在读取文件内容处构建一个输出流
final ctrl = StreamController<List<int>>();
final fileDataStream = ctrl.stream;

final entry = ZipFileEntry(
  name: fileName,
  data: fileDataStream,
);
// 在创建文件入口对象时同时创建文件数据对象,都是自定义的类
final data = _FileData(
  ctrl.sink,
  dataStartOffset,
  fileDataLen,
);

把所有的异步读取操作整个归到一起形成_readAsync,就像批处理一样整体一并操作,运行时机是创建了ZipArchive之后:

final archive = ZipArchive(entries);
_readAsync(file, dataItems);

  Future<RandomAccessFile> _readAsync(RandomAccessFile file, Iterable<_FileData> data) async {
    var f = file;
    for (final d in data) {
      f = await d.readStream(f);
    }
    return f;
  }

异步读取RandomAccessFile之后的对象,继续传递下去,而之前的_readStream方法改到_FileData之中:

final class _FileData {
  final StreamSink<List<int>> sink;
  final int position;
  final int length;

  _FileData(this.sink, this.position, this.length);

  Future<RandomAccessFile> readStream(RandomAccessFile file) async {
    const bufLength = 1 << 20;
    final end = position + length;
    var pos = position;
    var f = file;
    while (pos + bufLength < end) {
      f = await f.setPosition(pos);
      sink.add(await f.read(bufLength));
      pos += bufLength;
    }
    final len = end - pos;
    if (len > 0) {
      f = await f.setPosition(pos);
      sink.add(await f.read(len));
    }
    await sink.close();
    return f;
  }
}

如此一来,彻底解决了异步操作阻塞的问题。这一问题也让人意识到,即便都是随机访问文件,不同的语言也可能有不同的实现、不同的接口和不同的用法。最关键的,不仅仅是接口改成异步,而是对整个操作的认知和思维应当也改成异步的

想到两个问题:

  1. 调用_readAsync之前可否加上await
  2. _readAsync能否改成如下形式?
Future<RandomAccessFile> _readAsync(RandomAccessFile file, Iterable<_FileData> data) async {
  final futures = data.map((d) => d.readStream(file));
  await Future.wait(futures);
  return file;
}