0%

【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】

Dart 的 built_value 序列化功能

上週我介紹了使用 built_value 建立不可變物件模型。我們看到了如何在 built_value 中定義物件模型;它們是不可變的,易於使用,而且,如果您喜歡這種東西,會有很多樂趣。

本文涵蓋了 built_value 套件的其餘部分。最大的項目是,正如您可能從標題中猜到的那樣,它們也是可序列化的。

以下是 built_value 序列化的使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 使用 built_value 定義的值類型。
abstract class Login implements Built<Login, LoginBuilder> {
// 透過定義此靜態 getter 來加入序列化支援。
static Serializer<Login> get serializer => _$loginSerializer;

...
}

// 每個應用程式一次,定義一個頂級的「Serializer」來收集所有生成的序列化器。
Serializers serializers = _$serializers;

// 使用它!
var login = Login((b) => b
..username = 'johnsmith'
..password = '123456');

print(JSON.encode(serializers.serialize(login)));
-->
["Login", "username", "johnsmith", "password", "123456"]

注意到「JSON.encode」了嗎?序列化器實際上並沒有序列化為字串;而是轉換為 Dart 內建的 JSON 序列化知道如何處理的原始類型。因此,如果您願意,可以使用 JSON 以外的其他格式。

您可能認為序列化應該「正常工作」,但其中涉及一些微妙的權衡。讓我們深入研究 built_value 的序列化。

多型性

built_value 序列化的最重要的一個方面是它支援多型性。具體來說,您可以擁有抽象類型的欄位,並且

  • 該抽象類型的任何可序列化實作都可以被序列化;
  • 足夠的資訊將被寫入網路,以便反序列化為正確的類型。

最簡單的例子是它可以序列化一個 Object 列表:

1
2
3
serializers.serialize(BuiltList<Object>([1, 'two', 3]));
-->
['list', ['int', 1, 'string', 'two', 'int', 3]]

僅在反序列化時需要消除歧義時,才會在網路上加入額外資訊。因此,如果您有一個類型為「BuiltList」的欄位,它將被序列化為「[1, 2, 3]」,而不是「[‘int’, 1, ‘int’, 2, ‘int’, 3]」。

底線是您可以隨意定義您的物件模型,built_value 將對其進行序列化。如果您想更詳細地了解這一點,map 序列化器測試 探索了所有可能性。

多種實作

所有序列化機制都必須面對的另一個問題是以某種方式定義可序列化類型的範圍。這裡 built_json 做了一些不尋常的事情,允許一個「類型」的多種實作

這是可行的,因為類型在網路上是僅透過其類別名稱來定義的。例如,沒有嘗試區分不同的名為「Login」的類別;假設發送方和接收方都有一個名為「Login」的類別的相容序列化器可用。

這增加了有用的靈活性。例如,如果您在伺服器和客戶端上使用 Dart,那麼您可以在物件模型中為每個類別進行選擇

  • 您可以在客戶端和伺服器上使用相同的類別。
  • 或者,您可以使用不同的類別。這些實作必須具有相同的名稱和相容的欄位。

例如,您可以為客戶端擁有一個處理渲染和解析的「Login」類別;以及為伺服器擁有一個處理驗證和資料庫的單獨的「Login」類別。當然,僅限伺服器的實作可以自由使用「dart:io」等套件,僅限客戶端的實作可以使用「dart:html」等套件。

多種語言

由於 built_value 序列化僅透過類別名稱來識別類型,因此序列化資料可以很好地映射到任何物件導向語言。透過 AutoValue 計劃支援 Java。

多個版本

序列化 built_value 資料以一種非常簡單的方式向後/向前相容:它依賴於類別名稱和欄位名稱。類別名稱變更和必要的欄位名稱變更會導致不相容。

可為空的欄位更靈活:在序列化時,只有在非空時才會寫入它們;在反序列化時,如果找不到它們,則預設為空。因此,可以加入、移除或重新命名可為空的欄位,這不是一個不相容的變更。

無法辨識的欄位將被忽略。

無反射

最後,對於效能至關重要的一點是,built_value 不以任何形式使用反射。所有分析都在程式碼生成時完成,為您留下最少、高效能的序列化程式碼。

這就是使用 built_value 進行序列化。您可以坐下來編寫物件模型,它可以直接序列化以用於 RPC 或長期儲存。

EnumClass

最後,built_value 還附帶一個功能:EnumClass。Dart 列舉不是類別,但一個強大的物件模型需要像類別一樣運作的列舉。顯而易見的模式是建立一個帶有「static const」欄位的類別,而 EnumClass 使這更容易做到。它提供:

  • 為「values」和「valueOf」生成的程式碼。
  • 透過 built_value 序列化器進行序列化。
  • 給 Angular 或 Angular2 使用者的額外獎勵:程式碼生成可以選擇性地生成一個 mixin,以幫助您在模板中使用列舉。

所有這些功能都可以在範例 中看到。

本週就到這裡了!在介紹了 built_value 的基礎知識之後,我準備在下週詳細介紹聊天範例。敬請關注!

編輯:下一篇文章


Dart 的 built_value 序列化功能 最初發佈在 Medium 的 dartlang 上,人們在那裡透過突出顯示和回應這個故事來繼續討論。

【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】

Dart 的 built_value,實現不可變物件模型

上週我寫了關於 built_collection 的文章。最後我提到,要真正使用不可變集合,您需要不可變值。所以我們現在來談談 built_value。這是我在 Dart 開發者峰會 (影片) 上演講的第二個主要部分。

值類型

built_value 套件用於定義您自己的值類型。這個術語有精確的含義,但我們非正式地使用它來表示僅基於值判斷相等性的類型。例如,數字:我的 3 等於你的 3。

不僅如此:我的 3 將永遠等於你的 3;它不能變成 4,也不能變成 null,更不能變成完全不同的類型。值類型天生就是不可變的。這使得它們易於互動和推理。

這聽起來非常抽象。值類型有什麼用?事實證明:有很多用處。非常多。可以說——而且我經常這樣說——任何用於模擬現實世界的類都應該是值類型。觀察:

1
2
3
4
var user1 = User(name: "John Smith");
var user2 = User(name: "John Smith");

print(user1 == user2);

它應該印出什麼?關鍵是,這兩個實例都應該指向現實世界中的某個人。因為它們的值相同,所以它們必須指向同一個人。所以它們必須被認為是相等的。

那麼不可變性呢?考慮:

1
user1.nickname = 'Joe';

更新「使用者」暱稱意味著什麼?它可能意味著任何數量的更改;也許我的網頁上的歡迎文字使用了暱稱,那也應該更新。我可能在某個地方有一些儲存空間,那也需要更新。我現在有兩個主要問題:

  • 我不知道誰持有對 user1 的引用。該值在它們下面剛剛更改;根據它們的使用方式,這可能會產生任何數量不可預測的影響。
  • 任何持有 user2 或類似值的人現在都持有一個過時的值。

不可變性無法解決第二個問題,但它確實消除了第一個問題。這意味著沒有不可預測的更新,只有明確的更新:

1
2
var updatedUser = User(name: "John Smith", nickname: "Joe");
saveToDatabase(updatedUser); // 資料庫將通知前端。

關鍵是,這意味著更改是局部的,直到明確發佈。這會產生易於推理的簡單程式碼——並且使程式碼既正確又快速。

值類型的問題

那麼,顯而易見的問題是:如果值類型如此有用,為什麼我們沒有到處看到它們?

不幸的是,它們的實作非常費力。在 Dart 和大多數其他物件導向語言中,需要大量的樣板程式碼。在我於 Dart 開發者峰會上的演講中,我展示了一個簡單的雙欄位類需要如此多的樣板程式碼,以至於佔滿了整張投影片 (影片)。

介紹 built_value

我們需要一個語言特性——討論起來令人興奮,但不太可能很快出現——或者某種形式的元程式設計。我們發現 Dart 已經有一種非常好的元程式設計方法:source_gen

目標很明確:使定義和使用值類型變得如此容易,以至於我們可以在任何值類型有意義的地方使用它們。

首先,我們需要快速繞道,看看如何使用 source_gen 處理這個問題。source_gen 工具在您手動維護的原始碼旁邊的新檔案中建立生成的原始碼,因此我們需要為生成的實作留出空間。這意味著一個抽象類別:

1
2
3
4
5
6
abstract class User {
String get name;

@nullable
String get nickname;
}

這有足夠的資訊來產生一個實作。按照慣例,生成的程式碼以 _$ 開頭,以標記它是私有的和生成的。因此生成的實作將被稱為 _$User。為了允許它擴展 User,將有一個名為 _ 的私有建構函數用於此目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
=== user.dart ===

abstract class User {
String get name;

@nullable
String get nickname;

User._();
factory User() = UserImpl;
}

=== user.g.dart 由 source_gen 產生 ===

class _$User extends User {
String name;
String nickname;

_$User() : super._();
}

我們需要使用 Dart 的 part 陳述式來引入生成的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
=== user.dart ===

library user;

part 'user.g.dart';

abstract class User {
String get name;

@nullable
String get nickname;

User._();
factory User() = _$User;
}

=== user.g.dart 由 source_gen 產生 ===

part of user;

class _$User extends User {
String name;
String nickname;

_$User() : super._();

// 生成的實作放在這裡。
}

我們正在取得進展!我們有一種方法可以產生程式碼,並將其插入到我們手寫的程式碼中。現在回到有趣的部分:您實際上手寫的內容,以及 built_value 應該生成的內容。

我們缺少一種實際指定欄位值的方法。我們可以考慮使用具名可選參數:

1
factory User({String name, String nickname}) = _$User;

但這有幾個缺點:它強迫您在建構函數中重複所有欄位名稱,並且它只提供了一種一次設定所有欄位的方法;如果您想逐個構建值怎麼辦?

幸運的是,建構器模式 來拯救我們了。我們已經看到它在 Dart 中的集合中效果如何——感謝串聯運算符。假設我們有一個建構器類型,我們可以使用它作為建構函數——透過請求一個將建構器作為參數的函數:

1
2
3
4
5
6
7
8
9
abstract class User {
String get name;

@nullable
String get nickname;

User._();
factory User([updates(UserBuilder b)]) = _$User;
}

這有點令人驚訝,但它導致了一個非常簡單的實例化語法:

1
2
3
var user1 = User((b) => b
..name = 'John Smith'
..nickname = 'Joe');

如何根據舊值建立新值?傳統的建構器模式提供了一個 toBuilder 方法來轉換為建構器;然後您應用您的更新並調用 build。但對於大多數使用案例來說,一個更好的模式是有一個 rebuild 方法。與建構函數一樣,它接受一個以建構器為參數的函數,並提供簡單的內聯更新:

1
2
var user2 = user1.rebuild((b) => b
..nickname = 'Jojo');

不過,我們仍然需要 toBuilder,以防您想將建構器保留一段時間。因此,我們希望所有值類型都有兩種方法:

1
2
3
4
5
6
7
abstract class Built<V, B> {
// 建立一個新的實例:應用 [updates] 的實例。
V rebuild(updates(B builder));

// 轉換為建構器。
B toBuilder();
}

您不需要為這些方法編寫實作,built_value 將為您產生它。因此,您只需聲明您「實作 Built」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
library user;

import 'package:built_value/built_value.dart';

part 'user.g.dart';

abstract class User implements Built<User, UserBuilder> {
String get name;

@nullable
String get nickname;

User._();
factory User([updates(UserBuilder b)]) = _$User;
}

就這樣!定義了一個值類型,產生了一個實作,並且易於使用。當然,生成的實作不僅僅是欄位:它還提供了 operator==hashCodetoString 以及對必要欄位的 null 檢查。

不過,我跳過了一個主要細節:我說「假設我們有一個建構器類型」。當然,我們正在產生程式碼,所以答案很簡單:我們會為您產生它。User 中引用的 UserBuilder 是在 user.g.dart 中建立的。

除非您想在建構器中編寫一些程式碼,這是一個非常合理的做法。如果您想這樣做,您可以對建構器遵循相同的模式。它被聲明為抽象的,有一個私有建構函數和一個委託給生成的實作的工廠:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
abstract class UserBuilder implements Builder<User, UserBuilder> {
@virtual
String name;

@virtual
String nickname;

// 將例如 John "Joe" Smith 解析為使用者名稱 + 暱稱。
void parseUser(String user) {
...
}

UserBuilder._();
factory UserBuilder() = _$UserBuilder;
}

@virtual 註釋來自 package:meta,並且是允許生成的實作覆寫欄位所必需的。現在您已經將工具方法加入到您的建構器中,您可以像將其賦值給欄位一樣內聯使用它們:

1
var user = User((b) => b..parseUser('John "Joe" Smith'));

自訂建構器的使用案例相對少見,但它們可以非常強大。例如,您可能希望您的建構器實作一個用於設定共用欄位的通用介面,以便它們可以互換使用。

嵌巢建構器

built_value 還有一個您還沒有看到的主要功能:嵌巢建構器。當一個 built_value 欄位持有 built_collection 或另一個 built_value 時,預設情況下,它在建構器中作為嵌巢建構器提供。這意味著您可以更容易地更新深層嵌套的欄位,而不是整個結構都是可變的:

1
2
3
4
5
6
7
8
9
10
var structuredData = Account((b) => b
..user.name = 'John Smith'
..user.nickname = 'Joe'
..credentials.email = '[email protected]'
..credentials.phone.country = Country.us
..credentials.phone.number = '555 01234 567');

var updatedStructuredData = structuredData.rebuild((b) => b
..credentials.phone.country = Country.switzerland
..credentials.phone.number = '555 01234 555');

為什麼比整個結構都是可變的「更容易」?

首先,所有建構器提供的 update 方法意味著您可以隨時進入新的作用域,「重新啟動」串聯運算符,並簡潔地內聯進行您想要的任何更新:

1
2
3
4
5
6
var updatedStructuredData = structuredData.rebuild((b) => b
..user.update((b) => b
..name = 'Johnathan Smith')
..credentials.phone.update((b) => b
..country = Country.switzerland
..number = '555 01234 555'));

其次,嵌套建構器會根據需要自動建立。例如,在 built_value 的基準測試程式碼中,我們定義了一個名為 Node 的類型:

1
2
3
4
5
6
7
8
9
10
11
abstract class Node implements Built<Node, NodeBuilder> {
@nullable
String get label;
@nullable
Node get left;
@nullable
Node get right;

Node._();
factory Node([updates(NodeBuilder b)]) = _$Node;
}

建構器的自動建立讓我們可以內聯建立我們想要的任何樹結構:

1
2
3
4
5
6
7
var node = Node((b) => b
..left.left.left.right.left.right.label = 'I’m a leaf!'
..left.left.right.right.label = 'I’m also a leaf!');

var updatedNode = node.rebuild((b) => b
..left.left.right.right.label = 'I’m not a leaf any more!'
..left.left.right.right.right.label = 'I’m the leaf now!');

我提到基準測試了嗎?更新時,built_value 只複製需要更新的結構部分,重用其餘部分。所以它很快——而且記憶體效率很高*。

但您不必只構建樹。使用 built_value,您可以使用完全類型的不可變物件模型…它們與高效的不可變樹一樣快速和強大。您可以混合和匹配類型資料、自訂結構(如 Node 範例)以及來自 built_collection 的集合:

1
2
3
4
5
6
7
8
9
10
11
var structuredData = Account((b) => b
..user.update((b) => b
..name = 'John Smith')
..credentials.phone.update((b) => b
..country = Country.us
..number = '555 01234 567')
..node.left.left.left.account.update((b) => b
..user.name = 'John Smith II'
..user.nickname = 'Is lost in a tree')
..node.left.right.right.account.update((b) => b
..user.name = 'John Smith III'));

當我說大多數資料都應該是值類型時,我說的就是這些值類型!

更多關於 built_value

我已經討論了為什麼需要 built_value 以及它的使用方法。還有更多:built_value 還提供了 EnumClass,用於像列舉一樣的類別,以及 JSON 序列化,用於伺服器/客戶端通訊和資料儲存。我將在以後的文章中討論這些內容。

之後,我將深入研究聊天範例,該範例在具有伺服器和客戶端的端到端系統中使用 built_value

編輯:下一篇文章


Dart 的 built_value,實現不可變物件模型 最初發佈於 dartlang 的 Medium 上,人們在那裡透過醒目顯示和回應這個故事來繼續對話。