Привет, Хабр! Меня зовут Юра Кучанов @kuchanov, работаю Android разработчиком в Garage Eight и сегодня хочу рассказать о том, как мы делали Retrofit-подобную библиотеку для JSON-RPC протокола. Началось всё с того, что нам потребовалось для общения сервера и Android приложения использовать протокол JSON-RPC. Что значит “потребовалось”? Если кратко – бэкендеры предложили, а сильных аргументов против, в сущности, не нашлось =) Возможно, тут сработала, например, вот эта статья с хабра про выбор между REST и JSON-RPC. В итоге я пошёл искать библиотеки в сети и… И обнаружил, что готовые решения не подходят (так как там, конечно же, есть хотя бы один фатальный недостаток). В итоге сделал свою библиотеку в стиле Retrofit. Ниже расскажу, почему не подошли готовые решения, как реализовал своё через рефлексию и как копался в исходниках Retrofit и OkHttp для реализации нужного нам функционала.
Почему JSON-RPC и своя библиотека вместо стандартного REST API через Retrofit
Если вам удобнее видео формат, то вот тут есть запись с выступления на Mobius, а в тексте будет чуть больше подробностей, плюс ссылка на исходники.
Перед нами стояла задача – запустить с нуля новый продукт. То есть у нас своего рода стартап в рамках продуктовой компании. И свободы нам дали много – можно пробовать разное (в пределах разумного, конечно). На этапе выбора стэка технологий командой (я и Go-шник Илья) обсуждали несколько вариантов клиент-серверного общения и остановились на JSON-RPC протоколе. Он используется в основном продукте, также для бэкенда на Go в компании уже была своя проверенная библиотека и имелась в виду возможность в будущем перейти на реализацию от Google – gRPC. Однако, поспрашивав коллег по андроиду (тех, что основной продукт пилят) и изучив исходники оного, я выяснил, что клиент для JSON-RPC активно используется только на FrontEnd, а для андроида никаких решений в компании нет – там всё по привычному – REST API через Retrofit.
В итоге пошёл я в интернет смотреть, что же это за протокол такой. Выяснил следующее: JSON-RPC – это в первую очередь простой протокол. Он не указывает, какой тип транспорта вам использовать и не заставляет соблюдать множество сложных правил, число которых растёт год от года вместе с возможными интерпретациями этих правил. По ссылке вы можете найти исчерпывающее описание протокола. Оно крайне лаконичное, с последним обновлением в 2013 году. А вот ещё более упрощённая версия описания, нужная для понимания дальнейшего рассказа:
Клиент должен отправить на сервер JSON со следующими данными:
jsonrpc – версия протокола. Мы, конечно, используем вторую, самую свежую версию, засим отправляем всегда строчку "2.0" в качестве значения;
method – имя метода. Тут придётся решать одну из сложнейших вещей в нашей профессии – самим придумывать имена. Например: "user";
params – параметры метода. Можно просто массив с ними, но мы будем слать JSON с полями – так нагляднее, ибо у параметров есть имена;
id – идентификатор запроса. Может быть строкой, целым числом, null-ом. Мы будем использовать целые числа. Значение должно задаваться на клиенте, сервер в ответе пришлёт такой же ID.
Сервер обязательно ответит JSON-ом такого вида:
jsonrpc – версия протокола. Неудивительно, что приходить будет "2.0" в качестве значения;
id – идентификатор запроса. Значение должно быть точно такое же, какое было в запросе от клиента;
result – собственно результат вызова метода. Если запрос успешен, тут точно что-то будет. Что именно – зависит от метода. Например, такой JSON для метода user: { "id":1, "name": "Ivan" }. Или массив объектов. Или просто строка, число etc. А может - вообще придёт пустой объект в виде {};
error – исчерпывающая информация об ошибке. Если что-то пошло не так, то в ответе сервера будет это поле вместо поля result. Вот что будет и может быть внутри:
code – номер ошибки. Может быть только целым числом. Например: 42 – так мы поймём, например, что юзера с запрошенным ID не существует. Какой код за что отвечает, каждый решает сам, в протоколе это не прописано;
message – текст ошибки. Просто строка с описанием ошибки; Именно тут мы будем видеть всякие “Internal error” когда на бэке какой-то микросервис не задеплоится и всё сломается. Если же всё хорошо – тут будет человеко читаемый текст ошибки
data – опциональное поле с дополнительными данными. Сюда можете положить всё что угодно: более подробное описание ошибки, какое-то число или структуру для множества вложенных ошибок.
Собственно, вот и всё, что нам нужно знать для того, чтобы его использовать. Вот примеры запроса/ответа:
Успешный запрос:
--> {"jsonrpc": "2.0", "method": "subtract", "params": {"first": 42, "second": 23}, "id": 1} <-- {"jsonrpc": "2.0", "result": 19, "id": 1}
Неуспешный запрос к несуществующему методу:
--> {"jsonrpc": "2.0", "method": "foobar", "id": "2"} <-- {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "2"}
Что насчёт готовых реализаций?
Они, конечно, есть. Самая популярная и известная – gRPC от Google (не совсем JSON-RPC, скорее просто RPC, но мы планируем его потом использовать. На бэке - уже используем). Там реализации на нескольких языках и для сервера, и для клиента + всякие оптимизации типа proto-файлов с описанием всех запросов и с ответами к ним, по которым будет генерироваться код сервера и клиента. Однако это стало одной из причин для поиска другого, более простого решения. Мы хотели как можно быстрее начать писать код и не завязываться в самом начале на решение с кодогенерацией и сервера, и клиента, чтобы не мешать друг другу. Нашим BackEnd-ерам было проще: в компании уже была своя реализация протокола для Go, её они и взяли (через несколько месяцев, правда, к нам пришёл Go-шник Слава и таки затащил gRPC на бэк. Видимо, в будущем буду писать статью про то, как со своего велосипеда для JSON-RPC на gRPC под Android переезжали. Пока что у нас на нём только микросервисы меж собой общаются. А клиенты c бэком - через JSON-RPC). А меня отправили спрашивать коллег из основного продукта, где реализация под андроид. А её не было. Оказалось, что протокол успели поддержать на FrontEnd, а на Android оно почти не использовалось и отдельного решения никто не делал.
Но это не беда – поищем в интернете. И найдём несколько реализаций. Вот, например, очень пригодившаяся мне статья – A simple Retrofit-style JSON-RPC client. Там почти всё что нужно уже сделано и описано, но самой библиотеки я не нашёл. Да и там не хватало пары вещей типа возможности выбора либы для JSON и использовалась RxJava, а мы решили на модных корутинах писать. Ещё есть jsonrpc4j (с Jackson внутри и заточенную на использование на сервере на Spring) и Simple JSON-RPC (тоже Jackson внутри). В общем, у каждой хотя бы один недостаток – использованы неподходящие библиотеки, например Jackson для парсинга JSON и RxJava, либо библиотека была в т. ч. и для серверной части, что нам просто не нужно. Изучая варианты, я пришёл к выводу, что могу и сам реализовать всё, что нам нужно, подглядывая в исходники других библиотек.

Так как в сети есть примеры того, что нужно и что явно работает, то решено было делать так:
Смотрим в Retrofit, как из метода интерфейса, окружённого аннотациями, получается сетевой запрос.
Смотрим в найденные ранее библиотеки для JSON-RPC в поисках вдохновения для парсинга JSON и как они делают то, что хотим мы.
В OkHttp подглядим реализацию Interceptor: они нам точно пригодятся.
Приступим с самого начала. А там – рефлексия. Что же, придётся разбираться.
Рефлексия в Retrofit
Вспоминаем, что такое рефлексия. Рефлексия (от позднелат. reflexio – обращение назад) – это механизм исследования данных о программе во время её выполнения. Рефлексия позволяет исследовать информацию о полях, методах и конструкторах классов. Если заглянуть в исходники Retrofit, то обнаружится, что именно с помощью рефлексии осуществляется вся магия (хотя я почему-то когда-то давно думал, что там кодогенерация). Вспомним, как выглядит использование Retorfit:
Создаём интерфейс, описывающий запросы в сеть. Например UserApi. Методы и аргументы методов помечаем аннотациями.
Создаём экземпляр класса, делающего запросы в сеть – OkHttpClient.
С его помощью делаем экземпляр класса, создающего реализации интерфейса из п.1.
Как же, собственно, создаётся экземпляр класса, реализующего наш интерфейс? Для этого используется класс java.lang.reflect.Proxy. Он позволяет динамически, в runtime, создавать экземпляры классов, реализующих один или несколько интерфейсов. Для создания экземпляра Proxy требуется передать ему реализацию интерфейса java.lang.reflect.InvocationHandler, который предельно прост – всего один метод invoke. Именно в этом методе и происходит вся магия: он имеет всю информацию о вызываемом методе проксируемого интерфейса (имя, тип возвращаемого значения, аннотации etc) и все его аргументы, т. е. всё, что нужно, чтобы совершить те действия, которые нам требуются.
Таким образом, когда мы используем Retrofit, мы делегируем выполнение метода Proxy классу, а он направляет его InvocationHandler-у. Тот, наконец, передаёт вызов классу, который по значениям из аннотаций над методом, его аргументами, параметрами самого Retrofit и с помощью переданного ранее OkHttpClient сделает сетевой запрос.
Реализуем JSON-RPC
Вот мы и добрались до написания своего кода. Сделаем следующее:
Интерфейс JsonRpcClient с методом отправки запроса – принимаем JsonRpcRequest возвращаем JsonRpcResponse.
Реализуем интерфейс. Для реализации нам понадобятся:
адрес сервера;
OkHttpClient;
сериализатор параметров запроса;
десериализатор ответа сервера.
Реализуем InvocationHandler, а в нём:
сформируем JsonRpcRequest по информации, полученной с помощью рефлексии из вызываемого метода;
осуществим сетевой вызов с помощью JsonRpcClient;
десериализуем и вернём требуемые данные в случае успеха и прокинем ошибку в случае неудачи.
Соединяем всё вместе.
JsonRpcClient и описание JSON запроса и ответа
data class JsonRpcRequest( val id: Long, val method: String, val params: Map<String, Any?> = emptyMap(), val jsonrpc: String = "2.0" ) data class JsonRpcError( val message: String, val code: Int, val data: Any? ) data class JsonRpcResponse( val id: Long, val result: Any?, val error: JsonRpcError? ) interface JsonRpcClient { fun call(jsonRpcRequest: JsonRpcRequest): JsonRpcResponse }
JsonRpcClientImpl - делаем сетевой запрос, добавляем описания возможных ошибок и объявляем интерфейсы для сериализации/десериализации JSON
interface RequestConverter { fun convert(request: JsonRpcRequest): String } interface ResponseParser { fun parse(data: ByteArray): JsonRpcResponse } class NetworkRequestException( override val message: String?, override val cause: Throwable ) : RuntimeException(message, cause) class TransportException( val httpCode: Int, val response: Response, override val message: String?, override val cause: Throwable? = null ) : RuntimeException(message, cause) data class JsonRpcException( override val message: String, val code: Int, val data: Any? ) : RuntimeException(message) class JsonRpcClientImpl( private val baseUrl: String, private val okHttpClient: OkHttpClient, private val requestConverter: RequestConverter, private val responseParser: ResponseParser ) : JsonRpcClient { override fun call(jsonRpcRequest: JsonRpcRequest): JsonRpcResponse { val requestBody = requestConverter.convert(jsonRpcRequest).toByteArray().toRequestBody() val request = Request.Builder() .post(requestBody) .url(baseUrl) .build() val response = try { okHttpClient.newCall(request).execute() } catch (e: Exception) { throw NetworkRequestException( message = "Network error: cause = e ) } return if (response.isSuccessful) { response.body?.let { responseParser.parse(it.bytes()) } ?: throw IllegalStateException("Response body is null") } else { throw TransportException( httpCode = response.code, message = "HTTP ${response.code}. ${response.message}", response = response, ) } } }
Реализуем InvocationHandler. Чтобы получать информацию из аннотаций, не забываем аннотацию объявить. А также добавим единый источник значений для параметра ID запроса:
annotation class JsonRpc(val value: String) val requestId = AtomicLong(0) private fun <T> createInvocationHandler( service: Class<T>, client: JsonRpcClient, resultParser: ResultParser, ): InvocationHandler { return object : InvocationHandler { override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any { val methodAnnotation = method.getAnnotation(JsonRpc::class.java) ?: throw IllegalStateException("Method should be annotated with JsonRpc annotation") val id = requestId.incrementAndGet() val methodName = methodAnnotation.value val parameters = method.jsonRpcParameters(args, service) val request = JsonRpcRequest(id, methodName, parameters) val response = clinet.call(request) val returnType: Type = if (method.genericReturnType is ParameterizedType) { method.genericReturnType } else { method.returnType } if (response.result != null) { return resultParser.parse<Any>(returnType, response.result) } else { checkNotNull(response.error) throw JsonRpcException( response.error.message, response.error.code, response.error.data ) } } } } /** * Формируем данные для наполнения JsonRpcRequest */ private fun Method.jsonRpcParameters(args: Array<Any?>?, service: Class<*>): Map<String, Any?> { return parameterAnnotations .map { annotation -> annotation?.firstOrNull { JsonRpc::class.java.isInstance(it) } } .mapIndexed { index, annotation -> when (annotation) { is JsonRpc -> annotation.value else -> throw IllegalStateException( "Argument #" class="formula inline">index of name()" + " must be annotated with @ ) } } .mapIndexed { i, name -> name to args?.get(i) } .associate { it } }
Соединяем всё вместе, создавая прокси.
fun <T> createJsonRpcService( service: Class<T>, client: JsonRpcClient, resultParser: ResultParser, ): T { val classLoader = service.classLoader val interfaces = arrayOf<Class<*>>(service) val invocationHandler = createInvocationHandler( service, client, resultParser, ) return Proxy.newProxyInstance(classLoader, interfaces, invocationHandler) as T }
Теперь у нас всё готово, можем использовать.
Объявим интерфейс, пометим аннотациями:
interface UserApi { @JsonRpc("getUser") fun getUser(@JsonRpc("id") id: Int): User }
Создадим его экземпляр через Proxy, используя код, приведённый выше:
lateinit var userApi: UserApi private fun initJsonRpcLibrary() { val logger = HttpLoggingInterceptor.Logger { Log.d(TAG, it) } val loggingInterceptor = HttpLoggingInterceptor(logger).setLevel(HttpLoggingInterceptor.Level.BODY) val okHttpClient = OkHttpClient.Builder() .addInterceptor(loggingInterceptor) .build() val jsonRpcClient = JsonRpcClientImpl( baseUrl = BASE_URL, okHttpClient = okHttpClient, requestConverter = MoshiRequestConverter(), responseParser = MoshiResponseParser() ) userApi = createJsonRpcService( service = UserApi::class.java, client = jsonRpcClient, resultParser = MoshiResultParser() ) }
Запустим запрос через, например, корутины:
binding.getUserButton.setOnClickListener { lifecycleScope.launch { withContext(Dispatchers.IO) { try { val user = userApi.getUser(42) withContext(Dispatchers.Main) { binding.requestResponseTextView.text = user.toString() } } catch (e: Exception) { e.printStackTrace() if (e is JsonRpcException) { withContext(Dispatchers.Main) { Toast.makeText( this@MainActivity, "JSON-RPC error with code " class="formula inline">{e.code} and message ${e.message}", Toast.LENGTH_LONG ).show() binding.requestResponseTextView.text = e.toString() } } else { withContext(Dispatchers.Main) { Toast.makeText( this@MainActivity, e.message ?: e.toString(), Toast.LENGTH_LONG ).show() } } } } } }
Т.к. в OkHttpClient мы добавили перехватчик для логгирования, то в логах при успешном запросе увидим что-то такое:
D/JSON-RPC: --> POST http://192.168.43.226:8080/ D/JSON-RPC: Content-Length: 61 D/JSON-RPC: D/JSON-RPC: {"id":1,"method":"getUser","params":{"id":1},"jsonrpc":"2.0"} D/JSON-RPC: --> END POST (61-byte body) D/JSON-RPC: <-- 200 http://192.168.43.226:8080/ (69ms) D/JSON-RPC: Content-Type: application/json-rpc D/JSON-RPC: Content-Length: 62 D/JSON-RPC: Date: Tue, 03 May 2022 14:37:29 GMT D/JSON-RPC: Keep-Alive: timeout=60 D/JSON-RPC: Connection: keep-alive D/JSON-RPC: D/JSON-RPC: {"jsonrpc":"2.0","id":1,"result":{"id":1,"name":"User name"}} D/JSON-RPC: <-- END HTTP (62-byte body)
А как же Interceptor-ы для запросов?
Кажется, что наше решение прекрасно работает. Однако это не вполне так. Что, если нам надо как-то по-особенному реагировать на определённые ошибки, которые нам пришлёт сервер (протух токен, например) и/или надо модифицировать каждый запрос (добавлять токен к запросу)?
Часть этих потребностей можно решить через перехватчики на уровне OkHttp. Для этого договоримся, например, что токен мы будем прикреплять в заголовке запроса. Однако если токен на сервере захотят получать в теле запроса и/или если нам надо поудобнее обрабатывать ошибки, то нам не обойтись без собственного перехватчика. Давайте посмотрим, как это реализовано в OkHttp.

Если вы когда-то использовали перехватчики в OkHttp, то такой код будет вам знаком:
object : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain .request() .newBuilder() .header( "Authorization", "tokenValue" ) .build() return chain.proceed(request) } }
Вот как оно работает:
Добавляются 2 интерфейса: Chain и Interceptor.
Chain имеет метод proceed, принимающий запрос к серверу и возвращающий ответ сервера.
Interceptor имеет метод intercept, принимающий Chain и возвращающий ответ сервера.
Chain имеет реализацию RealInterceptorChain, принимающую в себя список Interceptor-ов и имеющую счётчик, по которому определяется, какой из цепочки Interceptor-ов следует вызывать.
С помощью счётчика происходит рекурсивный вызов Interceptor-ов.
В конец списка всех Interceptor-ов добавляется Interceptor, который непосредственно делает запрос на сервер, получая ответ оного.
В InvocationHandler вместо прямого вызова сервера создаётся RealInterceptorChain, в который передаются наши пользовательские Interceptor-ы в нужном нам порядке и вызывается метод intercept первого Interceptor-а в цепочке.
В итоге Interceptor-ы вызывают друг друга рекурсивно, пока не дойдут до последнего Interceptor-а, который вызовет сервера, после чего ответ сервера будет по цепочке возвращён к самому первому Interceptor-у. Таким образом, мы можем как модифицировать запрос, который по цепочке передаётся от одного Interceptor-а к другому, так и как-то отреагировать на ответ сервера.
Будем считать, что абстракция нам понятна, и попробуем прописать детали реализации.
Реализуем свои Interceptor-ы
Объявим интерфейс для цепочки:
interface Chain { fun proceed(request: JsonRpcRequest): JsonRpcResponse fun request(): JsonRpcRequest }
Объявим интерфейс для перехватчика:
interface JsonRpcInterceptor { fun intercept(chain: Chain): JsonRpcResponse }
Реализуем Chain.
data class RealInterceptorChain( private val client: JsonRpcClient, val interceptors: List<JsonRpcInterceptor>, private val request: JsonRpcRequest, private val index: Int = 0 ) : JsonRpcInterceptor.Chain { override fun proceed(request: JsonRpcRequest): JsonRpcResponse { // Call the next interceptor in the chain. Last one in chain is ServerCallInterceptor. val nextChain = copy(index = index + 1, request = request) val nextInterceptor = interceptors[index] return nextInterceptor.intercept(nextChain) } override fun request(): JsonRpcRequest = request }
Реализуем перехватчик, который будет делать запрос на сервер.
class ServerCallInterceptor(private val client: JsonRpcClient) : JsonRpcInterceptor { override fun intercept(chain: JsonRpcInterceptor.Chain): JsonRpcResponse { return client.call(chain.request()) } }
Теперь в нашем InvocationHandler используем RealInterceptorChain, добавив возможность передавать Interceptor-ы при создании прокси-класса:
fun <T> createJsonRpcService( ... interceptors: List<JsonRpcInterceptor> = listOf() ) : T { ... val invocationHandler = createInvocationHandler( ... interceptors ) ... } private fun <T> createInvocationHandler( ... interceptors: List<JsonRpcInterceptor> = listOf() ): InvocationHandler { return object : InvocationHandler { override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any { ... //val response = clinet.call(request) //добавляем перехватчик, который сделает запрос на сервер и получит от него ответ val serverCallInterceptor = ServerCallInterceptor(client) val finalInterceptors = interceptors.plus(serverCallInterceptor) val chain = RealInterceptorChain(client, finalInterceptors, request) //вместо прямого вызова через JsonRpcClient, вызываем intercept метод первого перехватчика в цепочке val response = chain.interceptors.first().intercept(chain) ... } } }
Собственно, всё. Теперь у нас есть весь нужный нам функционал, и мы можем модифицировать запросы как на транспортном уровне, так и на уровне нашей библиотеки. Вот пример перехвата ошибки протухшего токена, отправки запроса на получение нового и повтора оригинального запроса с уже новым токеном:
fun createAccessTokenExpiredJsonRpcInterceptor(): JsonRpcInterceptor { return object : JsonRpcInterceptor { override fun intercept(chain: JsonRpcInterceptor.Chain): JsonRpcResponse { val initialRequest = chain.request() val initialResponse = chain.proceed(initialRequest) return if (initialResponse.error != null && initialResponse.error?.code == 42) { try { val tokenResponse = // Отправляем запрос на получение нового токена // Сохраняем, например, токен в префах // и крепим его к каждому запросу в заголовке с помощью Interceptor из OkHttp //повторяем изначальный запрос chain.proceed(initialRequest) } catch (e: Exception) { throw e } } else { initialResponse } } } }
Каков итог и что можно улучшить?
Нас на данный момент устраивает то, что получилось. Но, конечно, можно и улучшить некоторые детали. Например, можно реализовать аналог CallAdapterFactory из Retrofit – они позволят в качестве типа возвращаемого значения методов наших интерфейсов использовать источники данных RxJava. Можно добавить больше реализаций интерфейсов парсинга JSON через другие библиотеки (Gson, Jackson etc). Ну и написать максимально подробную документацию, покрыть всё тестами и залить библиотеку в один из публичных репозиториев. Но это дело будущего. А исходники можно посмотреть на GitHub
Вот так, столкнувшись с интересной задачей, можно, основываясь на проектах с открытым исходным кодом, её успешно решить. Не бойтесь писать свой велосипед, если уже имеющиеся решения обладают хотя бы одним фатальным недостатком!
