Привет! Меня зовут Абакар, я работаю главным техническим лидером разработки в Альфа-Банке.

Думаю, многие задумывались о том, что происходит с функциями в интерфейсе Retrofit сервиса, когда мы помечаем их ключевым словом suspend? У некоторых даже есть заблуждение, что для сетевых запросов в таком случае используется корутиновский Dispatchers.IO. Спойлер — это не совсем так.

В этой статье мы как раз разберёмся, как всё работает на самом деле.


Начальные данные нашего проекта: у нас есть Retrofit + OkHttp — хвала великому и ужасному Джейку Уортону. Через эти библиотеки мы хотим делать сетевые запросы.
Описываем интерфейс нашего сервиса примерно так:

interface Service {

    @GET("endpoint")
    suspend fun exampleRequest()
}

Обратим внимание, что мы пометили функцию suspend модификатором — Retrofit сейчас поддерживает такое из коробки. И встаёт вопрос, а как он это делает? И на каком потоке в итоге будет выполняться мой запрос?

Важный дисклеймер: Retrofit использует механизм Java Dynamic Proxy, если ещё не приходилось про него слышать рекомендую посмотреть видео по этой теме.

Давайте поскорее погрузимся в это и разберемся.


Представим, что внутри нашей активити (для наглядности не стал усложнять пример дополнительными абстракциями) мы имеем такой код:

val retrofit = Retrofit.Builder().baseUrl("https://api.github.com").build()

val service = retrofit.create(Service::class.java)

lifecycleScope.launch {
    service.exampleRequest()
}

Обращаем внимание, что мы никак не переключаем пул потоков руками, но при этом и не получаем ошибку о том, что сетевой запрос выполняется на главном потоке. Давайте заглянем внутрь Retrofit и подебажим, что происходит при вызове функции exampleRequest.

Мы попадаем в loadServiceMethod, что находится в классе Retrofit.java, который сперва пытается достать из кэша (в данном случае кэшом выступает ConcurrentHashMap) одну из реализаций абстрактного класса ServiceMethod.

ServiceMethod — абстрактный класс внутри библиотеки Retrofit. У этого класса есть всего один абстрактный метод invoke, который как раз будет выполнять для нас запрос. На одну из его реализаций глянем позже.

Если в кэше по нашем ключу нет подходящего инстанса класса ServiceMethod , то мы создаём его и кладем в кэш:

Для создания класса ServiceMethod используется метод parseAnnotations — давайте заглянем в него:

Для начала у нас создается RequestFactory. Исходя из названия, можем сказать, что она будет управлять отправкой конкретных запросов. Проанализировав, что происходит внутри метода RequestFactory.parseAnnotations, мы в итоге дойдем до вызова метода build у класса RequestFactory.

Мы видим, что здесь мы как раз начинаем обрабатывать аннотации метода exampleRequest(), который вызывали выше. Может возникнуть вопрос — «А почему мы обрабатываем в цикле?»

Ответ простой — у нашего метода может быть несколько аннотаций, но в нашем случае она одна — GET.

Снизу видно значения из debug панели для наглядности
Снизу видно значения из debug панели для наглядности

Давайте немного передохнем и продолжим изучать, что находится в методе parseHttpMethodAndPath. Путь долгий, но мы обязательно докопаемся до истины!

Внутри метода parseHttpMethodAndPath видим, как в поля класса RequestFactory проставляется тип метода (GET) и есть ли флажок у метода body.

После этого идёт логика проверки, передавался ли в аннотацию @GET эндпоинт, по которому нужно делать запрос и используются ли query-параметры. Эта информация также присваивается в поля класса RequestFactory.

Итак, возвращаемся в метод build() , в рамках которого мы как раз обратывали аннотации (в нашем случае всего одну). Сразу скажу, я опущу проверки !hasBody или isMultipart , так как в рамках нашего разбора они не совсем интересны. Перейдем сразу к обработке сигнатуры нашего метода exampleRequest(), который мы описали в интерфейсе Service.

По дебагу заметно, что у нашего метода один параметр. Может возникнуть вопрос: «Как так, ведь мой метод не принимает никаких параметров?»

Всё дело в том, что наш метод помечен модификатором suspend, который делает так, что компилятор докидывает в него дополнительный параметр — Continuation. Об этом чуть подробнее можно почитать в моей статье «Корутины с точки зрения компилятора».

Мы видим, что наш параметр обрабатывается в метод parseParameter, который находится тут же в RequestFactory. Причем используется интересный прием — проверка p == lastParameter. Она нужна, чтобы получить булевский флаг allowContinuation. А мы помним о том, что компилятор добавляет Continuation последним параметром и Retrofit четко это проверяет.

Давайте посмотрим на реализацию метода parseParameter (намеренно пропущу ту часть кода, которая нам сейчас не совсем интересна):

Мы видим, что на основе вычисленного выше флага allowContinuation + проверки типа на Continuation.class Retrofit проставляет флажок isKotlinSuspendFunction в классе RequestFactory, который, я напомню, мы сейчас собираем. Какую роль играет этот флаг мы посмотрим чуть ниже.

Также важный момент заключается в том, что в случае наличия Continuation из этого метода должен вернуться null, как исключительный вариант обработки параметра метода. Возможно, не самое удачное решение с точки зрения дизайна метода, но кто мы такие, чтобы осуждать великого и ужасного :)

После обработки всех параметров метода (в нашем случае он был один), мы наконец можем создать нашу RequestFactory!

Описание некоторых проверок я также намеренно опустил, чтобы не раздувать статью
Описание некоторых проверок я также намеренно опустил, чтобы не раздувать статью

Итак, мы вернемся в наш ServiceMethod, в рамках которого создавали RequestFactory. Можете не беспокоиться, в конце статьи я приложу схему сущностей, как они друг от друга зависят и какие методы в них есть, чтобы всё это было нагляднее.

Возвращаемся к ServiceMethod:

Видим, что возвращаем сущность под названием HttpServiceMethod — и звучит действительно логично, мы ведь делаем обычный Http Get запрос. А ещё мы замечаем, что в метод parseAnnotations передается requestFactory, которую мы собрали до этого.

Обещаю — ещё немного и мы поймем, как Retrofit работает с корутинами и на каком потоке выполнится наш запрос. Итак пристегните ремни, мы летим вparseAnnotations! Уже в который раз))

Мы видим, как в рамках метода из requestFactory достаётся флаг isKotlinSuspendFunction, который мы совсем недавно туда бережно положили и на основе этого флага, начинает выполняться логика. Спойлер: если этого флага нет, то Retrofit просто сделает так: adapterType = method.getGenericReturnType() — то есть возьмет адаптер тайп по дефолту.

java.lang.reflect.Type — это корневой интерфейс в Java Reflection API. Он был введен в Java 5 для поддержки дженериков (generics) и работы с типами во время выполнения, несмотря на стирание типов (type erasure).

Мы можем заметить, что в ветвлении для suspend-функций есть проверки возвращаемого типа, например, suspend-функция не может возвращать Call класс, так как она и так по задумке будет выполняться асинхронно.

Call в Retrofit — это интерфейс, который представляет собой один HTTP-запрос. Это один из основных способов взаимодействия с сетевыми запросами в библиотеке Retrofit.

В нашем случае у функции возвращаемый тип Unit, поэтому в эти условия мы не попадаем и у нас просто проставится adapterType:

После этого у нас создается CallAdapter, в который при создании как раз передаётся adapterType, который мы получили выше:

CallAdapter в Retrofit — это компонент, который адаптирует тип возвращаемого значения методов интерфейса API. Он преобразует стандартный Call<T> в другие типы, которые могут быть более удобными для работы. Например, в конкретный возвращаемый тип, если функция помечена модификатором suspend.

Нажмём немного на ускоритель нашего исследовательского корабля, пропустим логику, которая нам пока не интересна, и переберемся к:

В зависимости от флага isKotlinSuspendFunction и того факта, есть ли у нашей функции возвращаемое значение или нет, выбирается необходимый инстанс HttpServiceMethod, который вернется из нашего метода. В данном случае это SuspendForBody. При этом, как мы видим, используются requestFactory и callAdapter, которые мы создали ранее. SuspendForBody наследуется от HttpServiceMethod, а HttpServiceMethod в свою очередь наследуется от ServiceMethod и переопределяет метод invoke:

Это приводит к вызову метода adapt, который переопределен в SuspendForBody:

Ура, мы наконец добрались до корутин! И мы видим, как из аргументов метода достается Continuation.

Внутри много разных условий, но наш случай попадает в самый первый, так как наша функция возвращает Unit. Обращаю внимание, что метод adapt возвращает Object — это может быть какой-либо осмысленный респонс, либо Unit, как в нашем кейсе.

Итак мы уже почти у цели. Залезаем внутрь KotlinExtensions.awaitUnit.

Видим использование корутиновского примитива suspendCancellableCoroutine.

suspendCancellableCoroutine — это функция-билдер в Kotlin Coroutines, которая позволяет преобразовать callback-стиль API в suspend-функции с поддержкой отмены (cancellation). В рамках этой функции доступен параметр continuation, на котором можно вызывать методы resume, resumeWithException.

В теле suspendCancellableCoroutine дергается стандартное API Retrofit, а именно метод enqueue , в который передается колбэк. Когда ответ возвращается в колбэк, мы можем дернуть continuation (что собственно в Retrofit и происходит) и результат отправится в место вызова.

Если проследим, куда ведет вызов enqueue, то попадём в класс RealCall из библиотеки okhttp, в его метод enqueue:

Как мы видим, всё приходит к использованию dispatcher и вызову на нём метода enqueue.

По дефолту используется реализация уже готового диспатчера. И это не корутиновский диспатчер. Проваливаемся внутрь:

А вот и место, где выполнится наш запрос. Дальше уже погружаться мы не будем, пожалеем свои нервы. Не будем смотреть, как внутри класса Dispatcher используются очереди и ExecutorService. Для нас важно, что он выполняет наши запросы и у него даже есть различного рода ограничения, которые можно настраивать.

Ну и напоследок, всё же под капотом используется Dispatcher, но не тот, о котором мы думали. А вообще, если их сравнить:

Dispatcher()    // okHttp
Dispatchers.IO // Coroutines

То можно найти очень похожую вещь, а именно:

Корутиновский Dispatchers.IO имплементирует интерфейс Executor из java.util.concurrent.
И если мы посмотрим внутрь Disptacher из OkHttp то увидим:

Для своей работы он использует ExecutorService, который также имплементирует интерфейс Executor. Такие дела.

Как и обещал, схема вызовов методов между сущностями.

Важный дисклеймер — схема приблизительная, чтобы проще было воспринять то, что мы рассматривали выше!


Ну вот мы и покопались немного в исходниках Retrofit и открыли для себя завесу тайны: как же он всё-таки работает с корутинами и почему я не ловлю краш, даже когда при вызове метода из Service не переключаю диспатчер на Dispatchers.IO.

Старался пробежаться как можно более коротко по всем важным сущностям, поэтому намеренно пропускал некоторые моменты. Если интересны статьи в подобном формате где разбираются популярные библиотеки оставляй комментарий!)

Также я веду YouTube канал на котором выпускаю видео по разного рода темам из IT. Подписывайся, если интересно!

Рекомендуем:

Разбираемся с Feature Toggle на примере Unleash
Привет, хабр! Меня зовут Егор, я бэкенд-разработчик в команде ЦФА в Альфа-Банке.  Сейчас мы рассматр...
habr.com
Зачем мы откатили прогресс с 85% до 79% в легаси-проекте?
У нас было: 11 общебанковских целевых сервисов, называемых платформой или платформенными сервисами, ...
habr.com
GitOps для Airflow: как мы перешли на лёгкий K8s-native Argo Workflows
Привет! Меня зовут Александр Егоров, я MLOps-инженер в Альфа-Банке, куда попал через проект компании...
habr.com
Любовь к ИИ, смерть и роботы
19 июля на ИТ‑фестивале UL Camp внезапно прошли ИИ‑дебаты в нашем шатре. Спонтанно мы решили спросит...
habr.com

Комментарии (0)