设计模式是有用的模板,可以帮助我们解决软件设计中常见的问题。
而当涉及到应用程序的架构时,结构设计模式可以帮助我们决定应用程序的不同部分是如何组织的。
在这种情况下,我们可以使用存储库模式来访问来自不同来源的数据对象,例如后端API,并将它们作为类型安全的实体提供给应用程序的领域层(也就是我们的业务逻辑所在)。
在这篇文章中,我们将详细了解资源库模式。
- 它是什么以及何时使用它
- 一些实际的例子
- 使用具体或抽象类的实现细节以及它们的取舍
- 如何用资源库测试代码
我还会分享一个带有完整源代码的天气应用实例。
准备好了吗?让我们潜心研究吧
什么是资源库设计模式?
为了理解这个问题,让我们考虑下面的架构图。
使用控制器、服务和存储库的Flutter应用架构
在这种情况下,存储库被发现在数据层。而它们的工作是
- 将领域模型(或实体)与数据层中数据源的实现细节隔离。
- 将数据传输对象转换为领域层所能理解的有效实体
- (可以选择)执行诸如数据缓存等操作。
上图显示的只是架构你的应用程序的许多可能方式之一。如果你遵循不同的架构,如MVC、MVVM或Clean Architecture,事情看起来会有所不同,但同样的概念也适用。
还要注意小部件是如何属于表现层的,它与业务逻辑或网络代码没有任何关系。
如果你的小部件直接与来自REST API或远程数据库的键值对一起工作,你就做错了。换句话说:不要把业务逻辑和你的UI代码混在一起。这将使你的代码更难测试、调试和推理。
什么时候使用资源库模式?
如果你的应用程序有一个复杂的数据层,有许多不同的端点返回非结构化的数据(如JSON),你想将其与应用程序的其他部分隔离,那么存储库模式就非常方便了。
更广泛地说,这里有几个我觉得资源库模式最合适的用例。
- 与REST APIs对话
- 与本地或远程数据库(如Sembast、Hive、Firestore等)对话
- 与特定设备的API对话(如权限、摄像头、位置等)。
这种方法的一个很大的好处是,如果你使用的任何第三方API出现了突破性的变化,你只需要更新你的版本库代码。
仅此一点,就使版本库100%值得。💯
所以,让我们看看如何使用它们吧🚀
仓库模式的实践
作为一个例子,我建立了一个简单的Flutter应用程序(这里是源代码),从OpenWeatherMap API中获取天气数据。
通过阅读API文档,我们可以发现如何调用API,以及一些JSON格式的响应数据的例子。
而资源库模式对于抽象出所有的网络和JSON序列化代码是非常好的。
例如,这里有一个抽象类,定义了我们存储库的接口。
abstract class WeatherRepository {
Future<Weather> getWeather({required String city});
}
上面的WeatherRepository ,只有一个方法,但可以有更多的方法(例如,如果你想支持所有的CRUD操作)。
重要的是,存储库允许我们定义一个合同,来检索某个城市的天气。
而且我们需要用一个具体的类来实现 WeatherRepository ,该类使用网络客户端(如http或dio)进行必要的API调用。
import 'package:http/http.dart' as http;
class HttpWeatherRepository implements WeatherRepository {
HttpWeatherRepository({required this.api, required this.client});
// custom class defining all the API details
final OpenWeatherMapAPI api;
// client for making calls to the API
final http.Client client;
// implements the method in the abstract class
Future<Weather> getWeather({required String city}) {
// TODO: send request, parse response, return Weather object or throw error
}
}
所有这些实现细节都是数据层关注的问题,应用程序的其他部分不应该关心或知道它们。
解析JSON数据
当然,我们还必须定义一个Weather 模型类(或实体),以及用于解析API响应数据的JSON序列化代码。
class Weather {
// TODO: declare all the properties we need
factory Weather.fromJson(Map<String, dynamic> json) {
// TODO: parse JSON and return validated Weather object
}
}
注意,虽然JSON响应可能包含许多不同的字段,但我们只需要解析那些将在用户界面中使用的字段。
我们可以手工编写JSON解析代码或使用代码生成包,如Freezed。要了解更多关于JSON序列化的信息,请看我的关于Dart中JSON解析的基本指南。
在应用程序中初始化资源库
一旦我们定义了一个资源库,我们就需要一种方法来初始化它,并使它能被应用程序的其他部分访问。
这样做的语法会根据你选择的DI/状态管理解决方案而改变。
这里有一个使用get_it的例子。
import 'package:get_it/get_it.dart';
GetIt.instance.registerLazySingleton<WeatherRepository>(
() => HttpWeatherRepository(api: OpenWeatherMapAPI(), client: http.Client(),
);
这是另一个使用Riverpod包的提供者的例子。
import 'package:flutter_riverpod/flutter_riverpod.dart';
final weatherRepositoryProvider = Provider<WeatherRepository>((ref) {
return HttpWeatherRepository(api: OpenWeatherMapAPI(), client: http.Client());
});
如果你使用flutter_bloc包,这里有一个类似的例子。
import 'package:flutter_bloc/flutter_bloc.dart';
RepositoryProvider<WeatherRepository>(
create: (_) => HttpWeatherRepository(api: OpenWeatherMapAPI(), client: http.Client()),
child: MyApp(),
))
底线是一样的:一旦你初始化了你的资源库,你就可以在你的应用程序的任何地方访问它(widgets, blocs, controllers, etc.)。
抽象类还是具体类?
在创建资源库时,一个常见的问题是:你真的需要一个抽象类,还是你可以直接创建一个具体的类,并取消所有的仪式?
这是一个非常有道理的问题,因为在两个类之间添加越来越多的方法会变得相当乏味。
abstract class WeatherRepository {
Future<Weather> getWeather({required String city});
Future<Forecast> getHourlyForecast({required String city});
Future<Forecast> getDailyForecast({required String city});
// and so on
}
class HttpWeatherRepository implements WeatherRepository {
HttpWeatherRepository({required this.api, required this.client});
// custom class defining all the API details
final OpenWeatherMapAPI api;
// client for making calls to the API
final http.Client client;
Future<Weather> getWeather({required String city}) { ... }
Future<Forecast> getHourlyForecast({required String city}) { ... }
Future<Forecast> getDailyForecast({required String city}) { ... }
// and so on
}
正如软件设计中经常出现的情况,答案是:这取决于。
因此,让我们来看看每种方法的一些优点和缺点。
使用抽象类
- 优点:我们可以在一个地方看到存储库的界面,没有所有的杂乱无章,这很好。
- 优点:我们可以用一个完全不同的实现来交换资源库(例如:
DioWeatherRepository,而不是HttpWeatherRepository),并且只需改变初始化代码中的一行,因为应用程序的其他部分只知道WeatherRepository。 - 缺点:当我们 "跳转到引用 "并把我们带到抽象类中的方法定义,而不是具体类中的实现时,VSCode会感到有点困惑。
- 缺点:更多的模板代码。
只使用具体类
- 优点。更少的模板代码。
- 优点:"跳转到引用 "只是工作,因为存储库的方法只在一个类中找到。
- 缺点:如果我们改变资源库的名称,换成不同的实现需要更多的改动(尽管用VSCode很容易在整个项目中重命名)。
当决定使用哪种方法时,我们还应该弄清楚如何为我们的代码编写测试。
用存储库写测试
在测试过程中,一个常见的要求是用模拟或 "假的 "来交换网络代码,以便我们的测试运行得更快、更可靠。
然而,抽象类在这里并没有给我们带来任何优势,因为在Dart中所有的类都有一个隐含的接口。
这意味着我们可以这样做。
// note: in Dart we can always implement a concrete class
class FakeWeatherRepository implements HttpWeatherRepository {
// just a fake implementation that returns a value immediately
Future<Weather> getWeather({required String city}) {
return Future.value(Weather(...));
}
}
换句话说,如果我们打算在测试中模拟我们的存储库,就没有必要创建抽象类。
事实上,像mocktail这样的包就利用了这一点,我们可以像这样使用它们。
import 'package:mocktail/mocktail.dart';
class MockWeatherRepository extends Mock implements HttpWeatherRepository {}
final mockWeatherRepository = MockWeatherRepository();
when(() => mockWeatherRepository.getWeather('London'))
.thenAnswer((_) => Future.value(Weather(...)));
嘲弄数据源
当你写测试时,你可以模拟你的存储库,并像我们上面那样返回预制的响应。
但是还有一个选择,那就是模拟底层数据源。
让我们回顾一下HttpWeatherRepository 是如何定义的。
import 'package:http/http.dart' as http;
class HttpWeatherRepository implements WeatherRepository {
HttpWeatherRepository({required this.api, required this.client});
// custom class defining all the API details
final OpenWeatherMapAPI api;
// client for making calls to the API
final http.Client client;
// implements the method in the abstract class
Future<Weather> getWeather({required String city}) {
// TODO: send request, parse response, return Weather object or throw error
}
}
在这种情况下,我们可以选择模拟传递给HttpWeatherRepository 构造函数的http.Client 对象。这里有一个测试的例子,显示了你可以如何做这件事。
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';
class MockHttpClient extends Mock implements http.Client {}
void main() {
test('repository with mocked http client', () async {
// setup
final mockHttpClient = MockHttpClient();
final api = OpenWeatherMapAPI();
final weatherRepository =
HttpWeatherRepository(api: api, client: mockHttpClient);
when(() => mockHttpClient.get(api.weather('London')))
.thenAnswer((_) => Future.value(/* some valid http.Response */));
// run
final weather = await weatherRepository.getWeather(city: 'London');
// verify
expect(weather, Weather(...));
});
}
最后,你可以根据你要测试的内容,选择是模拟版本库本身还是模拟 底层数据源。
了解了如何测试资源库后,让我们回到最初关于抽象类的问题。
存储库可能不需要一个抽象类
一般来说,如果你需要许多符合相同接口的实现,创建一个抽象类是有意义的。
例如,StatelessWidget 和StatefulWidget 都是Flutter SDK中的抽象类,因为它们是为了被子类化。
但是,当与资源库一起工作时,你可能只需要一个特定资源库的实现。
你有可能只需要一个给定资源库的实现,你可以将其定义为一个单一的具体类。
最低限度的共同标准
把所有东西都放在一个接口后面,也会把你锁定在选择具有不同功能的API之间的最低共同标准。
也许一个API或后端支持实时更新,这可以用一个基于流的API来建模。
但如果你使用的是纯REST(没有websockets),你只能发送一个请求并得到一个响应,这最好用基于Future的API建模。
处理这个问题很简单:只要使用基于流的API,如果你使用REST,只需返回一个有一个值的流。
但有时会有更广泛的API差异。
例如,Firestore支持交易和分批写入。这类API在引擎盖下使用构建器模式
,而这种方式不容易在通用接口后面被抽象出来。
如果你迁移到一个不同的后端,新的API有可能会有很大的不同。换句话说,为你当前的API提供未来保护往往是不切实际的,而且会产生反效果
。
存储库的横向扩展随着
你的应用程序的增长,你可能会发现自己在一个特定的存储库中添加越来越多的方法。
如果你的后端有一个大的API表面
,或者你的应用程序连接到许多不同的数据源,这很可能发生。
在这种情况下,考虑创建多个资源库,将相关的方法放在一起。例如,如果你正在建立一个电子商务应用程序,你可以为产品列表、购物车、订单管理、认证、结账等建立单独的存储库。
保持简单像
往常一样,保持简单的东西总是一个好主意。所以,不要过于纠结你的API。
你可以根据你需要使用的API来模拟你的版本库的接口,然后就可以了。如果需要的话,你可以随时重构。
结论如果
我希望你从这篇文章中得到一件事,那就是:
使用存储库模式来隐藏你的数据层的所有实现细节(例如JSON序列化)。因此,你应用程序的其他部分(领域和表现层)可以直接处理类型安全的
模型类/实体。而你的代码库也会对你所依赖的包中的破坏性变化变得更加有弹性。
如果有的话,我希望这个概述能够鼓励你更清楚地思考应用程序的架构,以及拥有独立的表现层、应用层、领域层和数据
层的重要性,并有明确的界限。
关于这个架构和每个单独层的更多细节,请查看本系列的其余文章: