非同步動態範圍
#本文探討 dart:async 函式庫中的區域相關 API,重點放在頂層 runZoned()
和 runZonedGuarded()
函式。在閱讀本文之前,請先檢閱 Futures 和錯誤處理 中涵蓋的技術。
區域可以執行以下任務
保護您的應用程式免於因未捕捉到的例外狀況而退出。例如,一個簡單的 HTTP 伺服器可能會使用以下非同步程式碼
dartrunZonedGuarded(() { HttpServer.bind('0.0.0.0', port).then((server) { server.listen(staticFiles.serveRequest); }); }, (error, stackTrace) => print('Oh noes! $error $stackTrace'));
在區域中執行 HTTP 伺服器,即使伺服器非同步程式碼中出現未捕捉到的(但非致命)錯誤,也能讓應用程式繼續執行。
關聯資料(稱為區域局部值)與個別區域。
覆寫有限的方法集,例如
print()
和scheduleMicrotask()
,在部分或全部程式碼中。每次程式碼進入或離開區域時執行操作。此類操作可能包括啟動或停止計時器,或儲存堆疊追蹤。
您可能在其他語言中遇到過類似區域的東西。Node.js 中的網域是 Dart 區域的靈感來源。Java 的執行緒局部儲存也有一些相似之處。最接近的是 Brian Ford 的 Dart 區域的 JavaScript 埠 zone.js,他在 這部影片 中描述了它。
區域基礎
#區域代表呼叫的非同步動態範圍。它是作為呼叫一部分執行的運算,以及該程式碼已註冊的非同步回呼(遞移)。
例如,在 HTTP 伺服器範例中,bind()
、then()
和 then()
的回呼都在同一個區域中執行,也就是使用 runZoned()
建立的區域。
在以下範例中,程式碼在 3 個不同的區域執行:區域 #1(根區域)、區域 #2 和 區域 #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...
下圖顯示程式碼的執行順序,以及程式碼在哪些區域執行。
每次呼叫 runZoned()
都會建立一個新區域,並在該區域執行程式碼。當該程式碼排程一個工作(例如呼叫 baz())時,該工作會在排程它的區域中執行。例如,呼叫 qux()(main() 的最後一行)會在 區域 #1(根區域)中執行,即使它附加到一個未來,而該未來本身會在 區域 #2 中執行。
子區域不會完全取代其父區域。相反地,新區域會嵌套在其周圍的區域內。例如,區域 #2 包含 區域 #3,而 區域 #1(根區域)包含 區域 #2 和 區域 #3。
所有 Dart 程式碼都在根區域中執行。程式碼也可能在其他嵌套的子區域中執行,但至少它總是會在根區域中執行。
處理未捕捉的錯誤
#區域能夠捕捉和處理未捕捉的錯誤。
未捕捉的錯誤通常是因為程式碼使用 throw
引發例外,而沒有附帶的 catch
陳述式來處理它。當 Future 以錯誤結果完成,但缺少對應的 await
來處理錯誤時,也可能在 async
函式中產生未捕捉的錯誤。
未捕捉的錯誤會回報給未捕捉到它的目前區域。預設情況下,區域會對未捕捉的錯誤以中斷程式回應。您可以安裝您自己的自訂未捕捉錯誤處理常式到新區域,以攔截和處理未捕捉的錯誤,隨您的喜好。
若要引入具有未捕捉錯誤處理常式的區域,請使用 runZoneGuarded
方法。它的 onError
回呼會變成新區域的未捕捉錯誤處理常式。此回呼會處理呼叫引發的任何同步錯誤。
runZonedGuarded(() {
Timer.run(() { throw 'Would normally kill the program'; });
}, (error, stackTrace) {
print('Uncaught error: $error');
});
其他促進未捕捉錯誤處理的區域 API 包括 Zone.fork
、Zone.runGuarded
和 ZoneSpecification.uncaughtErrorHandler
。
前述程式碼有一個非同步回呼(透過 Timer.run()
),會引發例外。通常,這個例外會是一個未處理的錯誤,並到達頂層(在獨立的 Dart 可執行檔中,會終止正在執行的程序)。然而,使用區域錯誤處理常式時,錯誤會傳遞給錯誤處理常式,而不會關閉程式。
try-catch 和區域錯誤處理常式之間一個顯著的差異是,區域會在發生未捕捉的錯誤後繼續執行。如果在區域內排程其他非同步回呼,它們仍然會執行。因此,區域錯誤處理常式可能會被呼叫多次。
任何具有未捕捉錯誤處理常式的區域稱為錯誤區域。錯誤區域可能會處理源自該區域後代的錯誤。一個簡單的規則決定在未來轉換序列中(使用 then()
或 catchError()
)錯誤在哪裡處理:Future 鏈上的錯誤絕不會跨越錯誤區域的界線。
如果錯誤到達錯誤區域界線,它會在那個點被視為未處理的錯誤。
範例:錯誤無法跨入錯誤區域
#在以下範例中,第一行引發的錯誤無法跨入錯誤區域。
var f = new Future.error(499);
f = f.whenComplete(() { print('Outside of zones'); });
runZoned(() {
f = f.whenComplete(() { print('Inside non-error zone'); });
});
runZonedGuarded(() {
f = f.whenComplete(() { print('Inside error zone (not called)'); });
}, (error) { print(error); });
如果您執行範例,您會看到以下輸出
Outside of zones
Inside non-error zone
Uncaught Error: 499
Unhandled exception:
499
...stack trace...
如果您移除對 runZoned()
或 runZonedGuarded()
的呼叫,您會看到以下輸出
Outside of zones
Inside non-error zone
Inside error zone (not called)
Uncaught Error: 499
Unhandled exception:
499
...stack trace...
請注意,移除區域或錯誤區域會導致錯誤進一步傳播。
堆疊追蹤會出現,因為錯誤發生在錯誤區域之外。如果您在整個程式碼片段周圍新增一個錯誤區域,則可以避免堆疊追蹤。
範例:錯誤無法離開錯誤區域
#正如前述程式碼所示,錯誤無法跨入錯誤區域。同樣地,錯誤也無法跨出錯誤區域。請考慮以下範例
var completer = new Completer();
var future = completer.future.then((x) => x + 1);
var zoneFuture;
runZonedGuarded(() {
zoneFuture = future.then((y) => throw 'Inside zone');
}, (error) { print('Caught: $error'); });
zoneFuture.catchError((e) { print('Never reached'); });
completer.complete(499);
即使未來鏈以 catchError()
結束,非同步錯誤也無法離開錯誤區域。runZonedGuarded()
中找到的區域錯誤處理常式會處理錯誤。因此,區域未來永遠不會完成 — 無論是透過值或錯誤。
將區域與串流搭配使用
#區域和串流的規則比未來更簡單
此規則遵循串流在被偵聽之前不應有任何副作用的準則。同步程式碼中類似的狀況是可迭代項的行為,在您要求值之前不會評估。
範例:使用 runZonedGuarded()
的串流
#以下範例使用回呼設定串流,然後在新的區域中使用 runZonedGuarded()
執行該串流
var stream = new File('stream.dart').openRead()
.map((x) => throw 'Callback throws');
runZonedGuarded(() { stream.listen(print); },
(e) { print('Caught error: $e'); });
runZonedGuarded()
中的錯誤處理常式會捕捉回呼引發的錯誤。以下是輸出
Caught error: Callback throws
正如輸出所示,回呼與偵聽區域關聯,而不是與呼叫 map()
的區域關聯。
儲存區域本機值
#如果您曾經想要使用靜態變數,但因為多個同時執行的運算互相干擾而無法使用,請考慮使用區域局部值。您可以新增一個區域局部值來協助除錯。另一個使用案例是處理 HTTP 要求:您可以在區域局部值中擁有使用者 ID 及其授權代碼。
使用 zoneValues
參數傳遞給 runZoned()
,以將值儲存在新建立的區域中
runZoned(() {
print(Zone.current[#key]);
}, zoneValues: { #key: 499 });
若要讀取區域局部值,請使用區域的索引運算子與值的鍵:[key]
。只要具有相容的 operator ==
和 hashCode
實作,任何物件都可以用作鍵。通常,鍵是符號文字:#identifier
。
您無法變更鍵對應的物件,但可以操作該物件。例如,下列程式碼會將項目新增至區域局部清單
runZoned(() {
Zone.current[#key].add(499);
print(Zone.current[#key]); // [499]
}, zoneValues: { #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); }));
}
此程式可以正常運作,但假設您現在想要知道每一行來自哪個檔案,而且您無法只將檔案名稱引數新增至 splitLinesStream()
。使用區域局部值,您可以將檔案名稱新增至傳回的字串(新的行已加亮顯示)
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')
.toList();
}
Future splitLines(filename) {
return runZoned(() {
return splitLinesStream(new File(filename).openRead());
}, zoneValues: { #filename: filename });
}
main() {
Future.forEach(['foo.txt', 'bar.txt'],
(file) => splitLines(file)
.then((lines) { lines.forEach(print); }));
}
請注意,新的程式碼不會修改函式簽章或將檔案名稱從 splitLines()
傳遞至 splitLinesStream()
。相反地,它使用區域局部值來實作類似於靜態變數的功能,該功能可在非同步環境中運作。
覆寫功能
#使用 zoneSpecification
引數傳遞至 runZoned()
以覆寫由區域管理的功能。引數的值是 ZoneSpecification 物件,您可以使用它來覆寫下列任何功能
- 分岔子區域
- 在區域中註冊和執行回呼函式
- 排程微任務和計時器
- 處理未捕捉的非同步錯誤(
runZonedGuarded()
是此功能的捷徑) - 列印
範例:覆寫列印
#以下是在區域內取消所有列印功能的覆寫功能範例
import 'dart:async';
main() {
runZoned(() {
print('Will be ignored');
}, zoneSpecification: new ZoneSpecification(
print: (self, parent, zone, message) {
// Ignore message.
}));
}
在分岔區域內,print()
函式會被指定的列印攔截器覆寫,該攔截器會直接捨棄訊息。之所以可以覆寫列印,是因為 print()
(例如 scheduleMicrotask()
和 Timer 建構函式)會使用目前的區域(Zone.current
)來執行其工作。
攔截器和委派者的引數
#正如列印範例所示,攔截器會將三個引數新增至區域類別對應方法中定義的引數。例如,區域的 print()
方法有一個引數:print(String line)
。由 ZoneSpecification 定義的 print()
攔截器版本有四個引數:print(Zone self, ZoneDelegate parent, Zone zone, String line)
。
三個攔截器引數總是會以相同的順序出現在任何其他引數之前。
self
- 處理回呼的區域。
parent
- 代表父區域的 ZoneDelegate。使用它將作業轉發給父區域。
zone
- 作業發生的區域。有些作業需要知道作業是在哪個區域呼叫的。例如,
zone.fork(specification)
必須建立一個新的區域作為zone
的子區域。再舉一個例子,即使你將scheduleMicrotask()
委派給另一個區域,執行微任務的仍然必須是原始的zone
。
當攔截器將方法委派給父區域時,父區域 (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);
}));
}
範例:執行進入和離開區域的程式碼
#假設你想要知道一些非同步程式碼花費多少時間執行。你可以將程式碼放入區域中,每次進入區域時啟動計時器,並在每次離開區域時停止計時器。
提供 run*
參數給 ZoneSpecification,讓你能夠指定區域執行的程式碼。
run*
參數(run
、runUnary
和 runBinary
)指定每次要求區域執行程式碼時要執行的程式碼。這些參數分別適用於零個參數、一個參數和兩個參數的回呼。run
參數也適用於在呼叫 runZoned()
之後執行的初始同步程式碼。
以下是使用 run*
設定檔程式碼的範例
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();
// ... Code that runs synchronously...
// ... Then code that runs asynchronously ...
.then((...) {
print(total.elapsedMilliseconds);
print(user.elapsedMilliseconds);
});
}, zoneSpecification: specification);
在此程式碼中,每個 run*
覆寫只會啟動使用者計時器,執行指定的函數,然後停止使用者計時器。
範例:處理回呼
#提供 register*Callback
參數給 ZoneSpecification,以包裝或變更回呼程式碼(在區域中非同步執行的程式碼)。與 run*
參數類似,register*Callback
參數有三個形式:registerCallback
(沒有參數的回呼)、registerUnaryCallback
(一個參數)和 registerBinaryCallback
(兩個參數)。
以下是一個範例,讓區域在程式碼消失到非同步內容之前儲存堆疊追蹤。
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);
}
繼續執行範例。您會看到「最後堆疊」追蹤 (lastStackTrace
),其中包含 foo()
,因為 foo()
是同步呼叫的。下一個堆疊追蹤 (stackTrace
) 來自非同步內容,它知道 bar()
但不知道 foo()
。
實作非同步回呼
#即使您實作非同步 API,您可能完全不需要處理區域。例如,雖然您可能預期 dart:io 函式庫會追蹤目前的區域,但它實際上依賴 dart:async 類別(例如 Future 和 Stream)的區域處理。
如果您明確處理區域,則您需要註冊所有非同步回呼,並確保每個回呼都在註冊它的區域中呼叫。Zone 的 bind*Callback
輔助方法讓這項工作變得更輕鬆。它們是 register*Callback
和 run*
的捷徑,確保每個回呼都註冊並在該 Zone 中執行。
如果您需要比 bind*Callback
提供的更多控制權,則您需要使用 register*Callback
和 run*
。您可能還想使用 Zone 的 run*Guarded
方法,它會將呼叫包進 try-catch 中,並在發生錯誤時呼叫 uncaughtErrorHandler
。
摘要
#區域很適合保護您的程式碼免於非同步程式碼中的未捕捉例外,但它們還能做更多事。您可以將資料關聯到區域,並且可以覆寫核心功能,例如列印和工作排程。區域能讓除錯更順利,並提供您可以用於剖析等功能的掛勾。
更多資源
#- 與區域相關的 API 文件
- 閱讀 runZoned()、runZonedGuarded()、Zone、ZoneDelegate 和 ZoneSpecification 的文件。
- stack_trace
- 使用 stack_trace 函式庫的 Chain 類別,您可以取得非同步執行程式碼的更佳堆疊追蹤。請參閱 pub.dev 網站上的 stack_trace 套件,以取得更多資訊。
更多範例
#以下是一些使用區域的更複雜範例。
- task_interceptor 範例
- 在 task_interceptor.dart 中的玩具區域會攔截
scheduleMicrotask
、createTimer
和createPeriodicTimer
,以模擬 Dart 原語的行為,而不會讓出事件迴圈。 - stack_trace 套件的原始碼
- stack_trace 套件 使用區域來形成堆疊追蹤鏈,以除錯非同步程式碼。使用的區域功能包括錯誤處理、區域局部值和回呼。您可以在 stack_trace GitHub 專案 中找到 stack_trace 原始碼。
- dart:html 和 dart:async 的原始碼
- 這兩個 SDK 函式庫實作的 API 具有非同步回呼功能,因此它們會處理區域。您可以在 sdk/lib 目錄 下瀏覽或下載它們的原始碼,該目錄位於 Dart GitHub 專案 中。
感謝 Anders Johnsen 和 Lasse Reichstein Nielsen 審閱本文。