你是不是曾经在代码里把UI、业务逻辑、网络请求混在一个类里,看起来像一锅大杂烩?我也这样做过 ✋。总而言之,APP开发是困难的。像领域驱动设计Domain-Driven Design (DDD) 之类的书可以帮助我们开发复杂的软件工程项目。DDD的核心是model,是我们要解决的问题需要掌握的重要知识和概念。一个好的领域模型是决定一个项目成功或失败的重要因素。模型很重要,但也不会脱离系统。最简单的app也需要一些UI(就是用户所看到好)和与服务端的接口交互,用来获取有意义的信息。
在app开发中,引入分层结构通常是有价值的,这样就可以在系统的不同部分之间有着明确的关注点分离。也能够使得我们的代码更加容易阅读、维护和测试。一般来说,我们通常可以把APP设计分为4层:
Data Layer是最底层,用来和外部数据交互,详细可见我之前的文章。
在Data Layer之上是「Domain」和「application」 Layer,这两层是业务逻辑和模型的关键部分。
本文,我们将聚焦在「domain layer」,使用一个购物APP作为练习。在本文你将学到以下内容:
维基百科有如下的定义:
❝The domain model is a conceptual model of the domain that incorporates both behavior and data. ❞
数据能够被一系列的实体和实体间的关系所表示,它们的行为能够通过实体类体现出业务逻辑并且能够被操作。
一个购物APP我们能够定义出如下实体:
❝当我们实践DDD时,实体和关系不是无中生有,而是一个知识发现过程的最终结果(有时很长时间)。作为该过程的一部分,领域词汇表也被形式化,供各部分使用。 ❞
请注意,在这个阶段,我们并不关心这些实体来自哪里,也不关心它们如何在系统中传递。
实体类是我们app的关键部分,因为它为用户解决了领域关系的难题。
❝在 DDD中, 经常会比较实体类和实体对象的区别,详细可以查看:Value vs Entity objects on StackOverflow(https://stackoverflow.com/questions/75446/value-vs-entity-objects-domain-driven-design) ❞
当我们构建APP,就需要实现这些实体类,并决定它们在App架构中的位置。所以我们需要在架构中引入domain layer。
我们再看看我们的app架构图:
如图所示,models正是在domain layer,它所处的位置是承上启下,能从下层 data layer获取数据,并且被上层的services layer进行数据处理。
下面我们来看看这些实体在dart中长什么样。
我们以Product这个实体为例:
/// The ProductID is an important concept in our domain
/// so it deserves a type of its own
typedef ProductID = String;
class Product {
Product({
required this.id,
required this.imageUrl,
required this.title,
required this.price,
required this.availableQuantity,
});
final ProductID id;
final String imageUrl;
final String title;
final double price;
final int availableQuantity;
// serialization code
factory Product.fromMap(Map<String, dynamic> map, ProductID id) {
...
}
Map<String, dynamic> toMap() {
...
}
}
这些属性就能够展示如下的界面:
其中包含的 fromMap() 和 toMap() 帮助我们进行序列化。
请记住 Product模型是一个简单的数据类,不需要访问repositories, services和其他领域层外的对象。
Model classes也能包含一些业务逻辑,也就意味着它可以被修改。
我们下面来看看一个购物车模型类的实现:
class Cart {
const Cart([this.items = const {}]);
/// All the items in the shopping cart, where:
/// - key: product ID
/// - value: quantity
final Map<ProductID, int> items;
factory Cart.fromMap(Map<String, dynamic> map) { ... }
Map<String, dynamic> toMap() { ... }
}
这里我们使用map来存储加入购物车的产品ID和对应的数量。我们还需要为购物车添加一个加入购物车和移出购物车的功能,我们使用extension方法来实现:
/// Helper extension used to update the items in the shopping cart.
extension MutableCart on Cart {
Cart addItem({required ProductID productId, required int quantity}) {
final copy = Map<ProductID, int>.from(items);
if (copy.containsKey(productId)) {
copy[productId] = quantity + copy[productId]!;
} else {
copy[productId] = quantity;
}
return Cart(copy);
}
Cart removeItemById(ProductID productId) {
final copy = Map<ProductID, int>.from(items);
copy.remove(productId);
return Cart(copy);
}
}
上面的方法先复制了一份购物车的列表,然后修改对应的值,最后返回新的immutable的Cart
对象。
❝许多状态管理的实现依赖于 **immutable objects,**这样能够正确的传递状态,并且是我们的widget能够在被正确的刷新 所以这里有一条规则,无论何时我们都不要使用mutate state,而是创建新的 「immutable copy。」 ❞
现在我们 Cart 类和 MutableCart extension 没有依赖任何领域层外的任何对象,所有对他们的测试相对容易。
下面我们实现一个针对addItem方法的单元测试:
void main() {
group('add item', () {
test('empty cart - add item', () {
final cart = const Cart()
.addItem(productId: '1', quantity: 1);
expect(cart.items, {'1': 1});
});
test('empty cart - add two items', () {
final cart = const Cart()
.addItem(productId: '1', quantity: 1)
.addItem(productId: '2', quantity: 1);
expect(cart.items, {
'1': 1,
'2': 1,
});
});
test('empty cart - add same item twice', () {
final cart = const Cart()
.addItem(productId: '1', quantity: 1)
.addItem(productId: '1', quantity: 1);
expect(cart.items, {'1': 2});
});
});
}
虽然单元测试不好写,但是能够保证我们APP的健壮性,所以大家还是多谢单测,少写bug。
本文讨论了好的领域模型对我们系统的重要性。也展示了如何定义实体类,以及使用immutable data方式处理我们的业务逻辑。最后也学习了如何为业务逻辑表现单元测试,领域层的单测比较简单,不会有复杂的mock和其他设置。
下面有一些设计和开发APP的小提示:
当你做到以上内容,并且思考哪些内容是和用户有关系并且需要在页面上展示的,你的App可能就是一个好用的app了。
目前不需要担心这些models是怎样在ui展示的,这些都是services和展示层的工作,下一篇文章讲详细的讲解。
少年别走,交个朋友~