Взаимодействие серверной части и мобильного приложения является неотъемлемой частью большинства приложений. Чаще всего для нереактивного обновления данных используется стандартный REST API, но как управлять промежуточными этапами: логировать сам запрос и его ответ, формировать и обрабатывать ошибки, управлять авторизацией и многое другое? Поговорим в статье от программистов digital-агентства «Vedita».
Dio… А что это?
Dio представляет собой мощный HTTP пакет для Dart/Flutter с поддержкой множества дополнительных инструментов, таких как интерсепторы, адаптеры, трансформеры и другие. Данный пакет позволяет создать глобальную конфигурацию своей сущности для каждого создаваемого объекта и в дальнейшем использовать данный объект без необходимости дублировать некоторые часто передаваемые параметры в конкретный источник данных (например ключ API или токен авторизации).
В документации представлена базовая информация по каждой его возможности, но хотелось бы зайти немного дальше и описать модель решения типовых задач для огромного количества кейсов, таких как: логирование, контроль авторизации, а также обновление токена авторизации, если ваша модель предусматривает вариант устаревания, которое может случиться даже во одной конкретной сессии.
Инициализация в проекте
Я предпочитаю инициализировать Dio во время инъекции зависимостей, чтобы в дальнейшем получать доступ к одному и тому же его объекту, так как в приложении зачастую используется больше одного API-провайдера, куда его необходимо передать. А также предварительно его можно сконфигурировать. Это может выглядеть следующим образом:
/// {@template app_dio_configurator.class}
/// The base class with client configuration of [Dio].
/// {@endtemplate}
class AppDioConfigurator {
/// {@macro app_dio_configurator.class}
const AppDioConfigurator(this.logger);
final RefinedLogger logger;
/// Creating a client [Dio].
Dio create({
Iterable? interceptors,
required String url,
String? proxyUrl,
}) {
const timeout = Duration(seconds: 30);
final dio = Dio();
dio.options
..baseUrl = url
..connectTimeout = timeout
..receiveTimeout = timeout
..sendTimeout = timeout;
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final client = HttpClient();
if (proxyUrl != null && proxyUrl.isNotEmpty) {
client
..findProxy = (uri) {
final url = 'PROXY $proxyUrl';
return url;
}
..badCertificateCallback = (_, __, ___) => true;
}
return client;
},
);
if (interceptors?.isNotEmpty ?? false) {
dio.interceptors.addAll(interceptors!);
}
return dio;
}
}
В таком случае мы получим предварительно настроенный Dio клиент для использования его в чистом виде, либо как часть конструкции нашего REST клиента
Формирование интерсепторов
Для создания собственного интерсептора нам необходимо унаследоваться от базового класса Interceptor, а также переопределить необходимые методы (onRequest, onResponse, onError). Также нашему наследнику не запрещено определять собственные поля/методы и задавать конструктор.
Возьмем в пример необходимость добавлять токен авторизации ко всем нашим запросам, а также бесшовно обновлять его и выполнять повторный запрос, если обновление прошло успешно. В обычном сценарии нам необходимо в каждый запрос передавать токен, отслеживать ошибку (обычно 401), выполнять уже другой запрос для обновления токена, а затем проверять успех выполненной операции и по необходимости повторять исходный запрос. При таком подходе все аспекты «чистого» кода начинают страдать, но здесь то нам и поможет механизм интереспетора.
Первоначально опишем поэтапно все необходимые сценарии:
-
Добавляем токен в каждый запрос (если он есть)
-
При получении ошибки авторизации (в нашем и большинстве случаев- ошибку с кодом 401) обрабатываем ее в виде попытки обновить токен на основе accessToken и refreshToken
-
В случае успеха берем обновленный токен и повторяем исходный запрос с обновленными заголовками. В случае ошибки обновления токена по причинам, связанным с самим обновлением (refreshToken также устарел, либо один из токенов является абсолютно не валидным) есть 2 варианта: удалить токены и вернуть исходную ошибку, либо удалить токены и повторить исходный запрос без них, когда такое предполагает наша бизнес-модель. Опишем наиболее сложную.
-
Каждый запрос будет проведен через методы интерсептора и аналогичные методы разных запросов работают параллельно друг с другом, но в рамках одного объекта. Это значит, что нам необходимо исключить множественные запросы обновления токена от разных запросов, но использовать наиболее актуальные данные.
-
Ошибки, которые «вылетят» из интерсептора и не являются DioException не попадут в catch блок функции-инициатора, стоит это учитывать.
-
При повторном запросе без токена необходимо учитывать только те из них, которые предполагают такой сценарий (см п.3). Для реализации используем параметр extra.
-
_cachedToken представляет собой AsyncCache.ephemeral(), что позволяет выполнять метод обновления только один раз и получить только 1 ответ, если текущий запрос находится в процессе (все прочие получат ответ первого запрошенного)
-
retryRequest вызывается из самописного миксина, который буквально берет переданный клиент, всю конфигурацию из response, но заменяет headers на переданные в метод. В моем случае заголовки передаются следующим образом: в случае с успехом обновления токена будут взяты исходные заголовки + подменен заголовок с токеном, а в случае ошибки, при наличии соответствующего extra заголовка, будут переданы исходные заголовки с удаленным из него вхождением с токеном.
-
RevokeTokenException — самописный класс, реализующий Exception для отслеживания ошибки обновления токена, чтобы явно отличить ее от любого другого сценария пробрасывания ошибки авторизации.
-
AuthorizationClient — самописный класс, принимающий Dio клиент, который используется исключительно для обновления токена и обработки соответствующей ошибки
p.s. Для выполнения запросов обновления токена и повтора запроса будет использоваться чистый (либо предварительно настроенный вами клиент). Если вы используете дополнительную валидацию на сервере (версию приложения/ключ апи и т.д.), убедитесь, что обновленные заголовки включают необходимые данные. Это могут быть перенесенные заголовки, либо использование тех же интерсепторов.
Для метода onRequest нам достаточно только добавлять токен в каждый запрос:
@override
Future onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await _loadToken();
final tokenHeaders = token == null ? const {} : _tokenHeaders(token);
options.headers.addAll(tokenHeaders);
handler.next(options);
}
@override
Future onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await _loadToken();
final tokenHeaders = token == null ? const {} : _tokenHeaders(token);
options.headers.addAll(tokenHeaders);
handler.next(options);
}
onResponse метод является ситуативным. Это зависит от того, какие ответы Dio будет считать «негативными» (возможно сконфигурировать, либо оставить по умолчанию). В нашем случае все ответы с 401 ошибкой будут обрабатываться в onError.
Метод onError предполагает основную работу с бесшовным обновлением токена и повтором запроса. Если вы, по какой-то причине, используете второстепенные клиенты с таким же интерсептором- обязательно учитывайте и не реагируйте на ошибку обновления токена также как на ошибку авторизации, иначе получится бесконечный цикл.
В результате метод выглядит следующим образом:
@override
Future onError(DioException err, ErrorInterceptorHandler handler) async {
final response = err.response;
/// Вернулась ошибка обновления токена, либо другая ошибка, не связанная с 401 кодом
final isNonHandledException = response == null || err.error is RevokeTokenException || !_shouldRefresh(response);
if (isNonHandledException) {
return handler.next(err);
}
try {
final handledResponse = await _handleAuthenticationFailedResult(response);
return handledResponse == null ? handler.next(err) : handler.resolve(handledResponse);
} on DioException catch (error) {
handler.next(error);
}
}
Рассмотрим вспомогательные методы, вызванные выше:
Метод обработки ошибки авторизации:
/// Попадаем сюда в случае с 401 ошибкой
/// Возвращаем обработанный ответ, либо null (если нужно оставить все как есть)
Future?> _handleAuthenticationFailedResult(Response response) async {
final token = await _loadToken();
try {
// В первую очередь пытаемся обновить токен
if (token != null) {
logger.error('Go refresh!');
final refreshedResponse = await _refresh(response, token);
return refreshedResponse;
}
// Если токен уже был удален (попытка обновить прошла не успешно)
// и запрос не чувствителен к токену (от наличия токена возвраащется разный результат, но не ошибка)
final shouldRetryWithoutToken = _shouldRetryWithoutAuthToken(response.requestOptions.extra);
if (shouldRetryWithoutToken) {
final newResponse = await retryRequest(
response: response,
headers: _removeTokenHeaders(response.requestOptions.headers),
retryClient: _retryClient,
);
return newResponse;
}
// В остальных случаях оставляем все как есть (не получилось обновить токен, также для запроса важен токен)
return null;
} on RevokeTokenException catch (e, s) {
final shouldRetryWithoutToken = _shouldRetryWithoutAuthToken(response.requestOptions.extra);
if (shouldRetryWithoutToken) {
final refreshedResponse = await retryRequest(
response: response,
headers: _removeTokenHeaders(response.requestOptions.headers),
retryClient: _retryClient,
);
return refreshedResponse;
}
throw DioException(requestOptions: response.requestOptions, error: e, response: response, stackTrace: s);
} on DioException {
rethrow;
} on Object catch (e, s) {
throw DioException(requestOptions: response.requestOptions, error: e, response: response, stackTrace: s);
}
}
Метод обновления токена:
Future> _refresh(Response response, Token token) async {
try {
// Refresh the token pair
final Token refreshedToken = await _cachedToken.fetch(() => authorizationClient.refresh(token));
// Save the new token pair
await tokenStorage.saveTokenPair(refreshedToken);
final tokenHeaders = _tokenHeaders(refreshedToken);
// Retry the request
final newResponse = await retryRequest(
response: response,
headers: response.requestOptions.headers..addAll(tokenHeaders),
retryClient: _retryClient,
);
return newResponse;
} on RevokeTokenException {
// Clear the token pair
logger.info('Revoking token pair');
await tokenStorage.clearTokenPair();
rethrow;
}
}
Проясним несколько не показанных моментов:
В результате мы получим контроль над авторизованностью пользователя при выполнении запроса и удобную обработку ошибок, что позволит выводить ее на экран, либо выполнять какие-то действия, не описывая их при выполнении каждого запроса.