Настройка API клиента с Dio
Чтобы код масштабировался, тестировался и был в целом надёжным, хорошим вариантом будет делать API клиент через паттерн репозиторий.
Архитектура и зависимости
Поделим код на три слоя:
- Сетевой слой -
Dio,AuthInterceptor,RemoteDataSources - Слой данных -
Repositories(абстрация, работа с DTO, маппинг в доменные модели и обработка ошибок) - Доменный слой - чистые модели и интерфейсы репозиториев (без зависимостей от Dio)
Для начала, добавим зависимости:
dio- собственно, наш клиент для APIjson_annotation- для удобного бингдинга полей JSON через декораторыflutter_secure_storage- для безопасного хранения токеновget_it- для DI (Dependency Injection)
И dev-зависимости:
build_runner- утилита для генерации кода, поможет нам с генерацией сериализуемых классовjson_serializable- декоратор для генерации сериализуемых в/из JSON классов
DTO и доменные модели
Тут сразу разделим: DTO (Data Transfer Object) - это класс, точно повторяющий структуру JSON из ответов API, а доменная модель - чистая модель бизнес-логики без привязки к JSON.
Представим, что у нас есть пользователь, а также определённый API эндпоинт может возвращать нам этого пользователя.
import 'package:json_annotation/json_annotation.dart';
part 'user_dto.g.dart';
// Доменная модель пользователя
class User {
final String id;
final String name;
User({required this.id, required this.name});
}
// DTO пользователя
@JsonSerializable()
class UserDto {
@JsonKey(name: 'user_id') // маппинг sneak_case из API
final String id;
final String name;
UserDto({required this.id, required this.name});
factory UserDto.fromJson(Map<String, dynamic> json) => _$UserDtoFromJson(json);
Map<String, dynamic> toJson() => _$UserDtoToJson(this);
// Маппинг DTO в доменную модель
User toDomain() => User(id: id, name: name);
}
Сетевой слой: настройка Dio и AuthInterceptor
Самая сложная часть нашей задумки - интерцептор авторизации. Он должен не только добавлять токен, но и обрабатывать его протухание, обновлять его и повторять запрос.
Для этого используем QueuedInterceptorsWrapper. Он ставит запросы в очередь, чтобы при 5 одновременных запросах с истёкшим токеном не произошло 5 попыток его обновить.
Хранилище токенов
abstract class TokenStorage {
Future<String?> getAccessToken();
Future<String?> getRefreshToken();
Future<void> saveTokens({required String access, required String refresh});
Future<void> clearTokens();
}
Auth Interceptor
import 'package:dio/dio.dart';
class AuthInterceptor extends QueuedInterceptorsWrapper {
final TokenStorage _tokenStorage;
final Dio _refreshDio; // отдельный клиент для запроса refresh, чтобы не зациклить интерцептор
final VoidCallback onUnauthorized;
AuthInterceptor({
required TokenStorage tokenStorage,
required Dio refreshDio,
required this.onUnauthorized
}) : _tokenStorage = tokenStorage,
_refreshDio = refreshDio;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await _tokenStorage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException error, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode != 401 ||
err.requestOptions.path.contains('/auth/refresh')) {
return handler.next(error);
}
try {
final refreshToken = await _tokenStorage.getRefreshToken();
if (refreshToken == null) throw Exception('No refresh token');
final resposne = await _refreshDio.posh('/auth/refresh', data: {'refresh_token': refreshToken});
final newAccess = response.data['access_token'];
final newRefresh = response.data['refresh_token'];
await _tokenStorage.saveTokens(access: newAccess, refresh: newRefresh);
err.requestOptions.headers['Authorization'] = 'Bearer $newAccess';
final retryResponse = await _refreshDio.fetch(err.requestOptions);
handler.resolve(retryResponse);
} catch (e) {
await _tokenStorage.clearTokens();
onUnauthorized();
handler.next(error);
}
}
}
Фабрика Dio
class DioFactory {
static Dio createDio({String baseUrl = 'https://api.example.com', List<Interceptor>? interceptors}) {
final dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
headers: {'Content-Type': 'application/json'},
));
dio.interceptors.addAll([
LogInterceptor(requestBody: true, responseBody: true), // только для dev-режима
if (interceptors != null) ...interceptors,
]);
return dio;
}
}
Сетевой слой: RemoteDataSources
"Data Sources" отвечает только за поход в сеть и возврат DTO. Он не знает о доменных моделях.
class ServerException implements Exception {
final String message;
ServerException(this.message);
}
abstract class UserRemoteDataSource {
Future<UserDto> getUser(String id);
}
class UserRemoteDataSourceImpl implements UserRemoteDataSource {
final Dio _dio;
UserRemoteDataSourceImpl(this._dio);
@override
Future<UserDto> getUser(String id) async {
try {
final response = await _dio.get('/users/$id');
return UserDto.fromJson(response.data);
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
throw NetworkException();
}
throw ServerException(e.response?.data['message'] ?? 'Server error');
} catch (e) {
throw ServerException(e.toString());
}
}
}
Слой данных: репозитории
Репозиторий - это мост между сетевым, локальным и доменным слоями. Он возвращает доменные модели и обрабатывает бизнес-исключения.
// Доменная ошибка
sealed class Failure {
final String message;
Failure(this.message);
}
class ServerFailure extends Failure {
ServerFailure(super.message);
}
class NetworkFailure extends Failure {
NetworkFailure() : super('Нет подключения к сети');
}
// Интерфейс репозитория (в доменном слое)
abstract class UserRepository {
Future<Either<Failure, User>> getUser(String id);
// Either<T> можно заменить на свой класс Result<T>
}
// Реализация в слое данных
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource _remoteDataSource;
UserRepositoryImpl(this._remoteDataSource);
@override
Future<Either<Failure, User>> getUser(String id) async {
try {
final userDto = await _remoteDataSource.getUser(id);
return Right(userDto.toDomain()); // маппим DTO в доменную модель
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on NetworkException catch (e) {
return Left(NetworkFailure());
}
}
}
Примечание:
Eitherиспользуется из пакетаdartzилиfpdart. Если не хочется использовать функциональщину, можно использовать стандартныйtry/catchс кастомным исключением илиResult<T>.
DI (сборка через get_it)
Собираем всё воедино. Важный момент: AuthInterceptor требует Dio для обновления токена, но этот Dio не должен иметь AuthInterceptor, иначе будет рекурсия.
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
Future<void> setupDI() async {
// Хранилище токенов
getIt.registerLazySingleton<TokenStorage>(() => SecureTokenStorage());
// Dio для refresh (без интерцептора!)
getIt.registerLazySingleton<Dio>(
() => DioFactory.createDio(baseUrl: 'https://api.example.com/auth'),
instanceName: 'refreshDio',
);
// Главный Dio
getIt.registerLazySingleton<Dio>(() {
final authInterceptor = AuthInterceptor(
tokenStorage: getIt<TokenStorage>(),
refreshDio: getIt<Dio>(instanceName: 'refresh_dio'),
onUnauthorized: () {
print('User logged out!');
},
);
return DioFactory.createDio(interceptors: [authInterceptor]);
});
// Data Sources
getIt.registerLazySingleton<UserRemoteDataSource>(
() => UserRemoteDataSourceImpl(getIt<Dio>()),
);
// Репозитории
getIt.registerLazySingleton<UserRepository>(
() => UserRepositoryImpl(getIt<UserRemoteDataSource>()),
);
}
Использование в BLoC или ViewModel
Теперь UI вообще не знает о существовании Dio, JSON или DTO. Он работает только с репозиторием пользователя (UserRepository) и доменной моделью пользователя (User).
class UserCubit extends Cubit<UserState> {
final UserRepository _userRepository;
UserCubit(this._userRepository) : super(UserInitial());
Future<void> loadUser(String id) async {
emit(UserLoading());
final result = await _userRepository.getUser(id);
result.fold(
(failure) => emit(UserError(failure.message)),
(user) => emit(UserLoaded(user)),
);
}
}
На что обратить внимание
- Два экземпляра
Dio- никогда не использовать единый экземпляр Dio для основного API и для запроса refresh токена. ИначеAuthInterceptorперехватит 401 от рефреша и уйдёт в бесконечный цикл. QueuedInterceptorsWrapper- обязательная штука для авторизации. Если на экране есть 3 виджета, которые делают запрос в API с протухшим токеном, тоQueueгарантирует, что рефреш вызовется один раз, а остальные 2 запроса дождутся обновления и снова уйдут вonRequest.- Генерация кода - всегда использовать
json_serializableилиfreezedдля DTO. Ручной парсингjson['key']- источник опечаток и багов. - Отмена запросов (CancelToken) - при уничтожении экрана обязательно нужно вызывать
cancelToken.cancel(), чтобы отменить "висящие" запросы и избежать утечек памяти или обновления несуществующего UI.