Dart学习笔记(32):Zone分区

发表于2018-07-04 13:17 阅读(39)

目录
1 基础知识
2 处理异步类型的错误信息
3 在分区中使用Stream数据流
4 存储zone-local value 分区本地值
….示例:在调试日志中使用分区本地值
5 Zone的重要功能
….示例一:重写print()函数
….示例二:委托父分区
….示例三:进入和离开分区时执行代码
….示例四:操作回调函数
6 总结

本文地址:http://www.cndartlang.com/899.html

本文将以顶层函数runZoned()为焦点,介绍dart:async中和Zone有关的API。在这之前,你应该已经熟悉Future和异常处理。

目前,最常见的使用Zone的情况是在异步代码中处理错误或异常信息。下面的代码是一个简单的Http服务器的实现,通过在分区中运行Http服务器,确保程序继续运行,尽管未在异步代码中捕获错误消息:

runZoned(() {
  HttpServer.bind('0.0.0.0', port).then((server) {
    server.listen(staticFiles.serveRequest);
  });
},
onError: (e, stackTrace) => print('Oh noes! $e $stackTrace'));

通常上面的代码并不总是需要使用分区,而Zone分区可能在下面的任务中使用:
1、保护你的程序因为异步代码抛出的异常未捕获而退出,如前面的示例代码。
2、与其他分区关联数据(分区本地值,类似于静态变量)。
3、在部分或全部代码中,优先于一系列受限的函数(如print()、scheduleTask()等)。
4、在每次代码进入或退出分区的时候,执行某操作,如开始或停止定时器、保持堆栈信息等。

你可能在其他语言中遇到过类似Zone的东西,而Dart中Zone的灵感确实来自于Node.js中的Domain,和Java中的线程本地存储TLS也有很多相似的地方。

1、基础知识

分区表示一个调用的异步、动态的范围。它包含一个计算过程(注册的匿名函数),并作为调用的部分被执行,但是该任务并不添加到事件循环中。

例如,在Http服务器的例子中,bind()、then()和then()注册的回调函数在同一个分区中被执行,该分区通过runZoned()创建。

在下面的示例中,代码在3个不同的分区中执行:zone #1(root zone)、zone #2和zone #3。

import 'dart:async';

main() {
  foo();
  var future;
  runZoned(() {          // Starts a new child zone (zone #2).
    future = new Future(bar).then(baz);
  });
  future.then(qux);
}

foo() => ...foo-body...  // Executed twice (once each in two zones).
bar() => ...bar-body...
baz(x) => runZoned(() => foo()); // New child zone (zone #3).
qux(x) => ...qux-body...

下图显示了代码执行的顺序,以及代码执行的Zone分区:

每次调用runZoned()都会创建一个新的分区,并执行分区中的代码。如果在代码中调度任务(如调用baz()),任务会在被调度代码所在的分区中执行。例如,在调用qux()(main函数中的最后一行)的时候,尽管then()附属于future,并且future在Zone #2中计算,但qux()依旧运行在Zone #1(根分区)中。

子分区并不会完全地替换父分区,新的分区会被嵌套进当前分区中。例如,Zone #2包含Zone #3,Zone #1(根分区)同时包含Zone #2和Zone #3。

需要注意的是,Dart代码虽然有可能在其他嵌套的子分区中执行,但是在最底层,未新建分区的时候,代码总是在root Zone根分区中运行。

2、处理异步类型的错误信息

开始的时候提到过,分区经常用来处理异步代码中的异常,这类似于同步代码中的try-catch。一般情况下,未捕获的异常是代码中throw语句主动地向上层抛出,由上层调用者处理。另一种未捕获的异常是通过new Future.error()或Completer的completeError()函数生成。

通常,我们使用runZoned()的命名参数onError来设置错误处理器,当分区中存在未捕获的异常时,异步错误处理器被调用。例如:

runZoned(() {
  Timer.run(() { throw 'Would normally kill the program'; });
}, onError: (error, stackTrace) {
  print('Uncaught error: $error');
});

上面的代码中,通过Timer.run()注册一个异步的回调函数并抛出异常 。正常情况下,如果没有设置异常处理器,该未捕获的异常会被抛出,直到顶层。在独立的Dart应用中,正在运行的进程会被Kill强制终止。

需要注意try-catch和Zone异常处理器之间的区别。Zone也可以捕获同步类型的异常,但是在Zone中,抛出异常位置之后的代码并不会执行,但是之前调度的异步回调函数会继续执行。因此,Zone的异常处理器可能会被调用多次。并且,处理的异常可能也来自与子分区或后代分区。为了明确异常的来源以及处理方式,建议使用catchError()捕获Future中的异常。如果异常到达Zone的边界而未被捕获,它将被视为未处理的异常被Zone抛出。

重要提示一:异常不会由外部跨进到分区

下面的代码中,第一行代码引发的异常信息不会跨越到分区中:

import 'dart:async';

main() {
  var f = new Future.error(499);
  f = f.whenComplete(() { print('Outside runZoned'); });
  f.catchError((e) => print(e));
  runZoned(() {
    f = f.whenComplete(() { print('Inside non-error zone'); });
  });
  runZoned(() {
    f = f.whenComplete(() { print('Inside error zone (not called)'); });
  }, onError: print);
}

如果运行代码,会出现下面的提示信息:

Outside runZoned
499
Inside non-error zone
Unhandled exception:
499
...stack trace...

如果删除第二个runZoned()的错误处理器onError,运行结果如下

Outside runZoned
499
Inside non-error zone
Inside error zone (not called)
Unhandled exception:
499
...stack trace...

注意,由上面的代码可以观察到,删掉runZoned的错误处理器后,会导致异常继续传递。但是,如果runZoned设置了错误处理器,那么注册的回调函数并不会执行,并且立即抛出异常,最终打印堆栈的错误信息。造成的原因是因为异常发生在Zone外部,错误并不会由外部跨越进Zone分区中。如果你将整个代码段添加到带错误处理器的分区中,则可以避免出错。

重要提示二:异常不会跳离出分区

像之前代码所显示的,异常不会由外部跨进到分区。同样,错误也不会调理出包含错误处理器的分区:

import 'dart:async';

main() {
  var completer = new Completer();
  var future = completer.future.then((x) => x + 1);
  var zoneFuture;
  
  runZoned(() {
    zoneFuture = future.then((y) => throw 'Inside zone');
  }, onError: (error) {
    print('Caught: $error');
  });
  
  zoneFuture.catchError((e) { print('Never reached'); });
  completer.complete(499);
}

尽管Future链的末尾设置了catchError(),异步类型的异常也没有跳出带错误处理器的分区,而是由分区的错误处理器进行处理。因此,上面代码中的zoneFuture肯定不会完成,既不是Value Future,也不是Error Future。

3、在分区中使用Stream数据流

相对于Future来说,Stream在Zone中的使用更简单。在Zone中,数据流在监听的时候进行转换或执行回调函数。遵循该规则的数据流在listen监听前,应该没有任何副作用。该情况类似于同步代码中的Iterable,在你取值前,不会进行任何的求值计算。下面是通过runZoned使用数据流的例子:

var stream = new File('stream.dart').openRead()
    .map((x) => throw 'Callback throws');

runZoned(() { stream.listen(print); },
         onError: (e) { print('Caught error: $e'); });

上面的代码中,通过回调函数抛出异常,并且被runZoned的错误处理器捕获。map()回调函数与分区中的listen有关,至于在何处设置map()并没有影响。输出结果应该为:

Caught error: Callback throws

4、存储zone-local value分区本地值

如果你想使用静态变量,但是担心被多个并发运行的计算影响,可以考虑使用分区本地值。当然,也可以通过分区本地值来进行代码调试。另一个用途是处理HTTP请求,通过分区本地值中进行授权操作。

在runZoned()中,可以使用zoneValues参数来存储新建分区中使用的值,而zoneValues的类型为Map:

runZoned(() {
  print(Zone.current[#key]);
}, zoneValues: { #key: 499 });

要读取分区本地值,使用分区的索引操作符 [] 以及值的Key。只要对象重载了 == 操作符,并实现了hashCode的get方法,都可以作为Key,如字符串、数字等。如果未检索到[key],则返回null。通常Key是一个原义符号Symbols:#identifier。

提示:Symbol 符号类表示一个标识符,用于代表Dart中定义的类、变量等等,#key等同于new Symbol(“key”)。对于混淆过的代码, Symbol 也返回混淆之前的标识符名字。后面的章节会有详细介绍。

分区中不能改变Key映射的值,但是你可以操作该对象。例如,下面的代码中,添加一条内容到分区本地值列表中:

runZoned(() {
  Zone.current[#key].add(499);
  print(Zone.current[#key]); // [499]
}, zoneValues: { #key: [] });

Zone可以从父分区中继承分区本地值,所以添加嵌套子孙分区并不会意外删除现有的值,并且在嵌套分区中可以改变,即重定义相同Key的值。建议使用唯一的对象作为Key,避免和其他库中的值发生冲突。

示例:在调试日志中使用分区本地值

如果有foo.txt和bar.txt两个文件,并且想输出所有行,代码如下:

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

Future splitLinesStream(stream) {
  return stream
      .transform(ASCII.decoder)
      .transform(const LineSplitter())
      .toList();
}

Future splitLines(filename) {
  return splitLinesStream(new File(filename).openRead());
}
main() {
  Future.forEach(['foo.txt', 'bar.txt'],
                 (file) => splitLines(file)
                     .then((lines) { lines.forEach(print); }));
}

上面的代码工作正常,但是假设你想知道每一行来自哪个文件。但是在splitLines()返回值为Stream,并没有恰当的方法在函数中合适的位置添加filename参数,而通过分区本地值,可以快速实现:

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

Future splitLinesStream(stream) {
  return stream
      .transform(ASCII.decoder)
      .transform(const LineSplitter())
      .map((line) => '${Zone.current[#filename]}: $line')    //Add
      .toList();
}

Future splitLines(filename) {
  return runZoned(() {    //Add
    return splitLinesStream(new File(filename).openRead());
  }, zoneValues: { #filename: filename });    //Add
}

main() {
  Future.forEach(['foo.txt', 'bar.txt'],
                 (file) => splitLines(file)
                     .then((lines) { lines.forEach(print); }));
}

新的代码中,并没有修改函数签名或在splitLines()与splitLinesStream()之间传递filename参数,而是使用分区本地值将数据流和filename相关联,仅添加3行代码就实现了该功能。

提示:函数由函数名、参数个数、参数类型、返回值组成,函数签名则是由函数名、参数个数与其类型组成。C++中,函数在重载时,利用函数签名的不同来区别到底该调用哪个方法。Dart中并不存在函数重载。

5、Zone的重要功能

在runZoned()中有一个命名参数zoneSpecification分区规范,类型为ZoneSpecification,可以通过该对象重写Zone管理的下列功能:

  • Fork子分区
  • 在分区中注册和运行回调函数
  • 调度Microtask微任务和Timer定时器
  • 处理未捕获的异步类型的异常
  • Print输出

示例一:重写print函数

下面的代码,实现了忽略分区中print函数输出的一种方法:

import 'dart:async';

main() {
  runZoned(() {
    print('Will be ignored');
  }, zoneSpecification: new ZoneSpecification(
      print: (self, parent, zone, message) {
        // Ignore message.
      }));
}

在fork(复制新分区作为子分区)的分区中,print()通过指定的print拦截器重写,实现丢弃、忽略输出消息的功能。重写print()函数是被允许的,因为print()(像scheduleMicrotask以及Timer构造函数一样)使用当前分区来工作。

关于参数中的拦截器和委托

前面的示例中,Zone的print(String line)只有一个参数,而ZoneSpecification中定义的拦截器(PrintHandler,输出处理器)却有4个参数:print(Zone self, ZoneDelegate parent, Zone zone, String line),并且前面3个参数总是以相同的顺序在ZoneSpecification中出现。

self
处理回调函数的分区

parent
ZoneDelegate分区委托表示父分区,通过它将操作转发给父分区。

zone
表示产生操作的位置。很多操作需要明确该操作是在哪个分区中被调用。例如,zone.fork(specification) 必须创建一个新的分区作为zone的子分区。另一个例子,即使你将scheduleMicrotask()委托给其他分区,微任务仍然在最初的分区中执行。

当拦截器将一个函数委托给parent的时候,函数的parent(ZoneDelegate)版本会添加一个额外的参数:zone。例如,在ZoneDelegate中print()的函数签名为print(Zone zone, String line)。

类似的,下面是可拦截函数scheduleMicrotask()的参数:
定义的位置                 函数签名
Zone                           void scheduleMicrotask(void f())
ZoneSpecification      void scheduleMicrotask(Zone self, ZoneDelegate parent, Zone zone, void f())
ZoneDelegate            void scheduleMicrotask(Zone zone, void f())

示例二:委托父分区

下面的示例将演示如何将任务委托给父分区执行:

import 'dart:async';

main() {
  runZoned(() {
    var currentZone = Zone.current;
    scheduleMicrotask(() {
      print(identical(currentZone, Zone.current));  // prints true.
    });
  }, zoneSpecification: new ZoneSpecification(
    scheduleMicrotask: (self, parent, zone, task) {
      print('scheduleMicrotask has been called inside the zone');
      // The origin `zone` needs to be passed to the parent so that
      // the task can be executed in it.
      parent.scheduleMicrotask(zone, task);
    }));
}

上面的代码中,分区中调度微任务的时候,会先打印提示信息print(‘scheduleMicrotask has been called inside the zone’);同样,上一个例子中,我们可以重新拼接字符串,在每次输出信息的时候打印日期:parent.print(zone, new DateTime.now().toString() + “\t” + message);

示例三:进入和离开分区时执行代码

如果你想知道异步代码执行所用时间,可以将代码放入分区中。当进入分区的时候,开始定时器;当离开分区的时候,停止定时器。关键的地方是,ZoneSpecification类提供了一套run*参数让分区执行指定的代码。

run*命名参数包括:run,runUnary和runBinary。作用为当Zone执行代码的时候,先执行指定代码。当回调函数的参数个数为0、1、2的时候,run、runUnary、runBinary分别被执行。同时,run参数也作用于初始化的时候,即调用runZoned()之后执行同步代码时。下面是使用run*分析代码的例子:

import 'dart:async';

main() {
  final total = new Stopwatch();
  final user = new Stopwatch();

  final specification = new ZoneSpecification(
      run: (self, parent, zone, f) {
        user.start();
        try { return parent.run(zone, f); } finally { user.stop(); }
      },
      runUnary: (self, parent, zone, f, arg) {
        user.start();
        try { return parent.runUnary(zone, f, arg); } finally { user.stop(); }
      },
      runBinary: (self, parent, zone, f, arg1, arg2) {
        user.start();
        try {
          return parent.runBinary(zone, f, arg1, arg2);
        } finally {
          user.stop();
        }
      });

  runZoned(() {
    total.start();
  // ... 执行同步代码...
  // ... 执行异步代码 ...
    new Future.delayed(new Duration(seconds: 1), () => null).then((_) {
      print(total.elapsedMilliseconds);
      print(user.elapsedMilliseconds);
    });
  }, zoneSpecification: specification);
}

运行结果(和计算机性能相关,非固定值):

1008
10

在这个代码中,每个run*参数只是用于启动定时器,执行指定的功能,然后停止定时器。
注:以后,Zone可能会提供更简单的onEnter/onLeave API。

示例四:操作回调函数

ZoneSpecification中的register*Callback用于封装或修改回调函数,并且在Zone中异步执行。该参数与run*类似,register*Callback有三种形式:registerCallback (针对回调函数没有参数的情况), registerUnaryCallback (回调函数是一个参数),registerBinaryCallback (回调函数是两个参数)。

下面是例子是异步模式下,在Zone遇到未捕获异常退出前,输出保存堆栈信息:

import 'dart:async';

get currentStackTrace {
  try {
    throw 0;
  } catch(_, st) {
    return st;
  }
}

var lastStackTrace = null;

bar() => throw "in bar";
foo() => new Future(bar);

main() {
  final specification = new ZoneSpecification(
      registerCallback: (self, parent, zone, f) {
        var stackTrace = currentStackTrace;
        return parent.registerCallback(zone, () {
          lastStackTrace = stackTrace;
          return f();
        });
      },
      registerUnaryCallback: (self, parent, zone, f) {
        var stackTrace = currentStackTrace;
        return parent.registerUnaryCallback(zone, (arg) {
          lastStackTrace = stackTrace;
          return f(arg);
        });
      },
      registerBinaryCallback: (self, parent, zone, f) {
        var stackTrace = currentStackTrace;
        return parent.registerBinaryCallback(zone, (arg1, arg2) {
          lastStackTrace = stackTrace;
          return f(arg1, arg2);
        });
      },
      handleUncaughtError: (self, parent, zone, error, stackTrace) {
        if (lastStackTrace != null) print("last stack: $lastStackTrace");
        return parent.handleUncaughtError(zone, error, stackTrace);
      });

  runZoned(() {
    foo();
  }, zoneSpecification: specification);
}

运行代码,在调用foo()后,你会看到控制台输出”last stack”堆栈信息(lastStackTrace),接着输出来自异步代码环境的堆栈信息(stackTrace),该信息来自bar()而不是foo()。

需要注意,即使你正在实现一个异步API,你可能也完全不必处理分区的问题。例如,你可能期望通过dart:io库对当前分区进行记录,但反而依赖分区来处理dart:async中的类(如Future和Stream)。

如果你显式地操作分区,需要注册所有异步回调函数,并确保每个回调函数在最初注册的分区中被调用。Zone中的bind*Callback函数使操作更容易,该函数是由register*Callback 和run*组合的简化函数,以确保在分区中每个回调函数都被注册和运行。

如果你需要比bind*Callback更细致的操作,那么你需要分开单独使用register*Callback 和run*。另外,分区中的run*Guarded函数对代码进行封装,将调用放入try-catch代码块中,如果出现错误,会调用uncaughtErrorHandler。

6、总结

当异步代码中存在未捕获异常的时候,Zone是一种很好的保护代码的方式,并且可以做得更多。你可以将通过分区本地值关联Zone中的数据,也可以重写print、任务调度等核心功能。同时,Zone可以更好地对代码进行调试,并且提供Hook机制对功能进行分析。