目录

转换器和编解码器

Florian Loitsch 著
2014 年 2 月(2015 年 3 月更新)

在计算机工程中,在不同的表示形式之间转换数据是一项常见任务。Dart 也不例外,它带有 dart:convert ,这是一个提供一系列转换器和用于构建新转换器的有用工具的核心库。该库提供的转换器示例包括用于 JSON 和 UTF-8 等常用编码的转换器。本文档展示了 Dart 的转换器如何工作,以及如何创建适合 Dart 世界的更高效的自定义转换器。

整体概览

#

Dart 的转换架构基于 转换器 ,它将一种表示形式转换为另一种表示形式。当转换是可逆的时,两个转换器将组合成一个 编解码器 (编码器-解码器)。编解码器一词经常用于音频和视频处理,但也适用于 UTF-8 或 JSON 等字符串编码。

按照惯例,Dart 中的所有转换器都使用 dart:convert 库中提供的抽象。这为开发人员提供了一致的 API,并确保转换器可以协同工作。例如,如果转换器(或编解码器)的类型匹配,则可以将它们融合在一起,然后可以将生成的转换器用作单个单元。此外,这些融合的转换器通常比单独使用时效率更高。

编解码器

#

编解码器是两个转换器的组合,一个进行编码,另一个进行解码:

dart
abstract class Codec<S, T> {
  const Codec();

  T encode(S input) => encoder.convert(input);
  S decode(T encoded) => decoder.convert(encoded);

  Converter<S, T> get encoder;
  Converter<T, S> get decoder;

  Codec<S, dynamic> fuse(Codec<T, dynamic> other) { .. }
  Codec<T, S> get inverted => ...;
}

可以看出,编解码器提供了诸如 encode()decode() 之类的便利方法,这些方法是用编码器和解码器来表达的。 fuse() 方法和 inverted getter 分别允许您融合转换器和更改编解码器的方向。 Codec 的基本实现 这两个成员提供了可靠的默认实现,实现者通常不必担心它们。

encode()decode() 方法也可以保持不变,但可以为其扩展附加参数。例如, JsonCodecencode()decode() 添加了命名参数,以使这些方法更有用:

dart
dynamic decode(String source, {reviver(var key, var value)}) { … }
String encode(Object value, {toEncodable(var object)}) { … }

编解码器可以使用用作默认值的参数进行实例化,除非在 encode() / decode() 调用期间被命名参数覆盖。

dart
const JsonCodec({reviver(var key, var value), toEncodable(var object)})
  ...

一般规则:如果编解码器可以配置,则应向 encode() / decode() 方法添加命名参数,并允许在构造函数中设置其默认值。 如果可能,编解码器构造函数应为 const 构造函数。

转换器

#

转换器,特别是它们的 convert() 方法,是实际转换发生的地方:

dart
T convert(S input);  // 其中 T 是目标类型,S 是源类型。

最小的转换器实现只需要扩展 Converter 类并实现 convert() 方法。与 Codec 类类似,可以通过扩展构造函数并将命名参数添加到 convert() 方法来使转换器可配置。

这种最小的转换器可在同步设置中工作,但在与块(同步或异步)一起使用时不起作用。特别是,这种简单的转换器不能用作转换器(转换器的更好特性之一)。完全实现的转换器实现了 StreamTransformer 接口,因此可以将其提供给 Stream.transform() 方法。

最常见的用例可能是使用 utf8.decoder 解码 UTF-8:

dart
File.openRead().transform(utf8.decoder).

分块转换

#

分块转换的概念可能令人困惑,但其核心相对简单。当启动分块转换(包括流转换)时,转换器的 startChunkedConversion 方法将使用输出接收器作为参数调用。然后,该方法返回一个输入接收器,调用者将数据放入其中。

分块转换

注意: 图中的星号 (*) 表示可选的多次调用。

在图中,第一步包括创建一个应该用转换后的数据填充的 outputSink 。然后,用户使用输出接收器调用转换器的 startChunkedConversion() 方法。结果是一个具有 add()close() 方法的输入接收器。

稍后,启动分块转换的代码可能会多次使用一些数据调用 add() 方法。数据由输入接收器转换。如果转换后的数据已准备就绪,则输入接收器将其发送到输出接收器,可能包含多个 add() 调用。最终,用户通过调用 close() 来完成转换。此时,任何剩余的转换数据都将从输入接收器发送到输出接收器,并且输出接收器将关闭。

根据转换器的不同,输入接收器可能需要缓冲部分传入数据。例如,接收 ab\ncd 作为第一个块的行分隔符可以安全地使用 ab 调用其输出接收器,但在处理 cd 之前需要等待下一个数据(或 close() 调用)。如果下一个数据是 e\nf ,则输入接收器必须连接 cde 并使用字符串 cde 调用输出接收器,同时缓冲 f 以等待下一个数据事件(或 close() 调用)。

输入接收器(与转换器结合)的复杂性各不相同。一些分块转换可以简单地映射到非分块版本(例如,删除字符 a 的 String→String 转换器),而另一些则更复杂。一种安全但效率低下的(通常不推荐的)实现分块转换的方法是缓冲和连接所有传入数据,并一次性进行转换。这就是 JSON 解码器当前(2014 年 1 月)的实现方式。

有趣的是,分块转换的类型不能从其同步转换中推断出来。例如, HtmlEscape 转换器同步地将字符串转换为字符串,异步地将字符串块转换为字符串块 (String→String)。 LineSplitter 转换器同步地将字符串转换为 List(各个行)。尽管同步签名不同,但 LineSplitter 转换器的分块版本与 HtmlEscape 的签名相同:String→String。在这种情况下,每个单独的输出块代表一行。

dart
import 'dart:convert';
import 'dart:async';

void main() async {
  // HtmlEscape 同步地将字符串转换为字符串。
  print(const HtmlEscape().convert("foo")); // "foo".
  // 当以分块方式使用时,它将字符串转换为字符串。
  var stream = new Stream.fromIterable(["f", "o", "o"]);
  print(await (stream.transform(const HtmlEscape())
                     .toList()));    // ["f", "o", "o"].

  // LineSplitter 同步地将字符串转换为字符串列表。
  print(const LineSplitter().convert("foo\nbar")); // ["foo", "bar"]
  // 但是,异步地它将字符串转换为字符串(而不是字符串列表)。
  var stream2 = new Stream.fromIterable(["fo", "o\nb", "ar"]);
  print("${await (stream2.transform(const LineSplitter())
                          .toList())}");
}

一般来说,分块转换的类型由用作 StreamTransformer 时最有效的情况决定。

分块转换接收器

#

分块转换接收器 用于向转换器添加新数据或用作转换器的输出。基本的分块转换接收器包含两个方法: add()close() 。这些方法的功能与系统中的所有其他接收器(如 StringSinksStreamSinks 相同。

分块转换接收器的语义类似于 IOSinks : 添加到接收器的数据不得修改,除非可以保证数据已被处理。对于字符串来说,这不是问题(因为它们是不可变的),但对于字节列表来说,这通常意味着分配列表的新副本。这可能效率低下,因此 dart:convert 库带有 ChunkedConversionSink 的子类,这些子类支持更高效的数据传递方式。

例如, ByteConversionSink 具有附加方法:

dart
void addSlice(List<int> chunk, int start, int end, bool isLast);

从语义上讲,它 接受一个列表(可能不会保留),转换器操作的子范围,以及一个布尔值 isLast ,可以设置它而不是调用 close()

dart
import 'dart:convert';

void main() {
  var outSink = new ChunkedConversionSink.withCallback((chunks) {
    print(chunks.single); // 𝅘𝅥𝅯
  });

  var inSink = utf8.decoder.startChunkedConversion(outSink);
  var list = [0xF0, 0x9D];
  inSink.addSlice(list, 0, 2, false);
  // 由于我们使用了 `addSlice` ,因此允许我们重用列表。
  list[0] = 0x85;
  list[1] = 0xA1;
  inSink.addSlice(list, 0, 2, true);
}

作为分块转换接收器(用作转换器的输入和输出)的用户,这只是提供了更多选择。列表不会被保留这一事实意味着您可以使用缓存并将其重用于每次调用。将 add()close() 结合使用可能会有所帮助,因为它可以避免缓冲数据。接受子列表可以避免对 subList() 的昂贵调用(复制数据)。

此接口的缺点是实现起来更复杂。为了减轻开发人员的负担,dart:convert 的每个改进的分块转换接收器也带有一个基类,该基类实现了除一个(它是抽象的)之外的所有方法。然后,转换接收器的实现者可以决定是否利用附加方法。

注意:_分块转换接收器 必须 扩展相应的基类。这确保了向现有接收器接口添加功能不会破坏扩展的接收器。

示例

#

本节展示了创建简单的加密转换器所需的所有步骤,以及自定义 ChunkedConversionSink 如何提高性能。

让我们从简单的同步转换器开始,其加密例程只是根据给定的密钥旋转字节:

dart
import 'dart:convert';

/// 将 Rot13 简单扩展到字节和密钥。
class RotConverter extends Converter<List<int>, List<int>> {
  final _key;
  const RotConverter(this._key);

  List<int> convert(List<int> data, { int key }) {
    if (key == null) key = this._key;
    var result = new List<int>(data.length);
    for (int i = 0; i < data.length; i++) {
      result[i] = (data[i] + key) % 256;
    }
    return result;
  }
}

相应的 Codec 类也很简单:

dart
class Rot extends Codec<List<int>, List<int>> {
  final _key;
  const Rot(this._key);

  List<int> encode(List<int> data, { int key }) {
    if (key == null) key = this._key;
    return new RotConverter(key).convert(data);
  }

  List<int> decode(List<int> data, { int key }) {
    if (key == null) key = this._key;
    return new RotConverter(-key).convert(data);
  }

  RotConverter get encoder => new RotConverter(_key);
  RotConverter get decoder => new RotConverter(-_key);
}

我们可以(也应该)避免一些 new 分配,但为了简单起见,我们每次需要时都会分配一个新的 RotConverter 实例。

这就是我们使用 Rot 编解码器的方式:

dart
const Rot ROT128 = const Rot(128);
const Rot ROT1 = const Rot(1);

void main() {
  print(const RotConverter(128).convert([0, 128, 255, 1]));   // [128, 0, 127, 129]
  print(const RotConverter(128).convert([128, 0, 127, 129])); // [0, 128, 255, 1]
  print(const RotConverter(-128).convert([128, 0, 127, 129]));// [0, 128, 255, 1]

  print(ROT1.decode(ROT1.encode([0, 128, 255, 1])));          // [0, 128, 255, 1]
  print(ROT128.decode(ROT128.encode([0, 128, 255, 1])));      // [0, 128, 255, 1]
}

我们走在正确的道路上。编解码器有效,但仍然缺少分块编码部分。因为每个字节都是单独编码的,所以我们可以回退到同步转换方法:

dart
class RotConverter {
  ...
  RotSink startChunkedConversion(sink) {
    return new RotSink(_key, sink);
  }
}

class RotSink extends ChunkedConversionSink<List<int>> {
  final _converter;
  final ChunkedConversionSink<List<int>> _outSink;
  RotSink(key, this._outSink) : _converter = new RotConverter(key);

  void add(List<int> data) {
    _outSink.add(_converter.convert(data));
  }

  void close() {
    _outSink.close();
  }
}

现在,我们可以将转换器用于分块转换,甚至用于流转换:

dart
import 'dart:io';

void main(List<String> args) {
  String inFile = args[0];
  String outFile = args[1];
  int key = int.parse(args[2]);
  new File(inFile)
    .openRead()
    .transform(new RotConverter(key))
    .pipe(new File(outFile).openWrite());
}

特殊化的 ChunkedConversionSinks

#

对于许多目的来说,当前版本的 Rot 就足够了。也就是说,改进的好处将被更复杂的代码和测试要求的成本所抵消。但是,让我们假设转换器的性能至关重要(它位于热点路径上并在概要分析中)。我们进一步假设为每个块分配一个新列表的成本正在降低性能(这是一个合理的假设)。

我们首先通过使用 类型化字节列表 来降低分配成本,我们可以将分配列表的大小减少 8 倍(在 64 位机器上)。这一行更改并没有消除分配,但使其变得便宜得多。

如果我们覆盖输入,我们也可以完全避免分配。在以下版本的 RotSink 中,我们添加了一个新方法 addModifiable() ,它正是这样做的:

dart
class RotSink extends ChunkedConversionSink<List<int>> {
  final _key;
  final ChunkedConversionSink<List<int>> _outSink;
  RotSink(this._key, this._outSink);

  void add(List<int> data) {
    addModifiable(new Uint8List.fromList(data));
  }

  void addModifiable(List<int> data) {
    for (int i = 0; i < data.length; i++) {
      data[i] = (data[i] + _key) % 256;
    }
    _outSink.add(data);
  }

  void close() {
    _outSink.close();
  }
}

为简单起见,我们提出了一种使用完整列表的新方法。更高级的方法(例如 addModifiableSlice() )将采用范围参数( fromto )和一个 isLast 布尔值作为参数。

转换器尚未使用此新方法,但我们已经在显式调用 startChunkedConversion() 时可以使用它了。

dart
void main() {
  var outSink = new ChunkedConversionSink.withCallback((chunks) {
    print(chunks); // [[31, 32, 33], [24, 25, 26]]
  });
  var inSink = new RotConverter(30).startChunkedConversion(outSink);
  inSink.addModifiable([1, 2, 3]);
  inSink.addModifiable([250, 251, 252]);
  inSink.close();
}

在这个小例子中,性能并没有明显的不同,但在内部,分块转换避免了为各个块分配新的列表。对于两个小的块来说,这没有区别,但是如果我们为流转换器实现这一点,加密更大的文件可能会明显更快。

为此,我们可以利用 IOStreams 提供的可修改列表的未公开功能。我们现在可以简单地重写 add() 并将其直接指向 addModifiable() 。一般来说,这不是安全的,这样的转换器可能是难以追踪的错误的潜在来源。相反,我们编写一个显式执行不可修改到可修改转换的转换器,然后融合这两个转换器。

dart
class ToModifiableConverter extends Converter<List<int>, List<int>> {
  List<int> convert(List<int> data) => data;
  ToModifiableSink startChunkedConversion(RotSink sink) {
    return new ToModifiableSink(sink);
  }
}

class ToModifiableSink
    extends ChunkedConversionSink<List<int>, List<int>> {
  final RotSink sink;
  ToModifiableSink(this.sink);

  void add(List<int> data) { sink.addModifiable(data); }
  void close() { sink.close(); }
}

ToModifiableSink 只向下一个接收器发出信号,表示传入的块是可修改的。我们可以用它来提高管道的效率:

dart
void main(List<String> args) {
  String inFile = args[0];
  String outFile = args[1];
  int key = int.parse(args[2]);
  new File(inFile)
      .openRead()
      .transform(
          new ToModifiableConverter().fuse(new RotConverter(key)))
      .pipe(new File(outFile).openWrite());
}

在我的机器上,这个小小的修改将 11MB 文件的加密时间从 450ms 减少到 260ms。我们在不失去与现有编解码器(关于 fuse() 方法)的兼容性的情况下实现了这种速度提升,并且转换器仍然可以用作流转换器。

重用输入与其他转换器配合良好,而不仅仅是与我们的 Rot 密码配合使用。因此,我们应该创建一个概括此概念的接口。为简单起见,我们将其命名为 CipherSink ,尽管它当然在加密世界之外也有用途。

dart
abstract class CipherSink
    extends ChunkedConversionSink<List<int>, List<int>> {
  void addModifiable(List<int> data) { add(data); }
}

然后我们可以使我们的 RotSink 私有并公开 CipherSink。其他开发人员现在可以使用我们的工作(CipherSink 和 ToModifiableConverter)并从中受益。

但我们还没完成。

尽管我们不会再使密码更快,但我们可以改进 Rot 转换器的输出端。例如,两个加密的融合:

dart
void main(List<String> args) {
  String inFile = args[0];
  String outFile = args[1];
  int key = int.parse(args[2]);
  // 双强度密码,两次运行 Rot 密码。
  var transformer = new ToModifiableConverter()
       .fuse(new RotConverter(key))  // <= 融合的 RotConverters。
       .fuse(new RotConverter(key));
  new File(inFile)
      .openRead()
      .transform(transformer)
      .pipe(new File(outFile).openWrite());
}

由于第一个 RotConverter 调用 outSink.add() ,因此第二个 RotConverter 假设输入不能被修改并分配一个副本。我们可以通过在两个密码之间插入 ToModifiableConverter 来解决这个问题:

dart
  var transformer = new ToModifiableConverter()
       .fuse(new RotConverter(key))
       .fuse(new ToModifiableConverter())
       .fuse(new RotConverter(key));

这有效,但很笨拙。我们希望 RotConverters 在没有中间转换器的情况下工作。第一个密码应该查看 outSink 并确定它是否是 CipherSink。我们可以这样做,每当我们想要添加一个新的块时,或者在我们启动分块转换时。我们更喜欢后者:

dart
  /// 如果给定 CipherSink 作为参数,则效率更高。
  CipherSink startChunkedConversion(
      ChunkedConversionSink<List<int>> sink) {
    if (sink is! CipherSink) sink = new _CipherSinkAdapter(sink);
    return new _RotSink(_key, sink);
  }

_CipherSinkAdapter 只是:

dart
class _CipherSinkAdapter implements CipherSink {
  ChunkedConversionSink<List<int>, List<int>> sink;
  _CipherSinkAdapter(this.sink);

  void add(data) { sink.add(data); }
  void addModifiable(data) { sink.add(data); }
  void close() { sink.close(); }
}

现在我们只需要更改 _RotSink 来利用这样一个事实,即它总是接收一个 CipherSink 作为其构造函数的参数:

dart
class _RotSink extends CipherSink {
  final _key;
  final CipherSink _outSink;  // <= 总是 CipherSink。
  _RotSink(this._key, this._outSink);

  void add(List<int> data) {
    addModifiable(data.toList());
  }

  void addModifiable(List<int> data) {
    for (int i = 0; i < data.length; i++) {
      data[i] = (data[i] + _key) % 256;
    }
    _outSink.addModifiable(data);  // <= 安全地调用 addModifiable。
  }

  void close() {
    _outSink.close();
  }
}

通过这些更改,我们的超级安全的双重密码不会分配任何新的列表,我们的工作就完成了。

感谢 Lasse Reichstein Holst Nielsen、Anders Johnsen 和 Matias Meno 在撰写本文方面给予的大力帮助。