Все библиотеки имеют фатальные недостатки, или Как мы изобретали Retrofit-подобный велосипед для JSON-RPC протокола

Привет, Хабр! Меня зовут Юра Кучанов @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 году. А вот ещё более упрощённая версия описания, нужная для понимания дальнейшего рассказа:

  1. Клиент должен отправить на сервер JSON со следующими данными:

    • jsonrpc — версия протокола. Мы, конечно, используем вторую, самую свежую версию, засим отправляем всегда строчку »2.0» в качестве значения;

    • method — имя метода. Тут придётся решать одну из сложнейших вещей в нашей профессии — самим придумывать имена. Например: «user»;

    • params — параметры метода. Можно просто массив с ними, но мы будем слать JSON с полями — так нагляднее, ибо у параметров есть имена;

    • id — идентификатор запроса. Может быть строкой, целым числом, null-ом. Мы будет использовать целые числа. Значение должно задаваться на клиенте, сервер в ответе пришлёт такой же ID.

  2. Сервер обязательно ответит 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. Там реализации на нескольких языках и для сервера, и для клиента + всякие оптимизации типа proto-файлов с описанием всех запросов и с ответами к ним, по которым будет генерироваться код сервера и клиента. Однако это стало одной из причин для поиска другого, более простого решения. Мы хотели как можно быстрее начать писать код и не завязываться в самом начале на решение с кодогенерацией и сервера, и клиента, чтобы не мешать друг другу. Нашим BackEnd-ерам было проще: в компании уже была своя реализация протокола для Go, её они и взяли (через несколько месяцев, правда, к нам пришёл Go-шник Слава и таки затащил gRPC на бэк. Видимо, в будущем буду писать статью про то, как со своего велосипеда на gRPC под Android переезжали). А меня отправили спрашивать коллег из основного продукта, где реализация под андроид. А её не было. Оказалось, что протокол успели поддержать на FrontEnd, а на Android оно почти не использовалось и отдельного решения никто не делал.

Но это не беда — поищем в интернете. И найдём несколько реализаций. Вот, например, очень пригодившаяся мне статья — A simple Retrofit-style JSON-RPC client. Там почти всё что нужно уже сделано и описано, но самой библиотеки я не нашёл. Да и там не хватало пары вещей типа возможности выбора либы для JSON и использовалась RxJava, а мы решили на модных корутинах писать. Ещё есть jsonrpc4j (с Jackson внутри и заточенную на использование на сервере на Spring) и Simple JSON-RPC (тоже Jackson внутри). В общем, у каждой хотя бы один недостаток — использованы неподходящие библиотеки, например Jackson для парсинга JSON и RxJava, либо библиотека была в т. ч. и для серверной части, что нам просто не нужно. Изучая варианты, я пришёл к выводу, что могу и сам реализовать всё, что нам нужно, подглядывая в исходники других библиотек.

6e38c4067e5b37a453ea60d1a41d15f0.png

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

  1. Смотрим в Retrofit, как из метода интерфейса, окружённого аннотациями, получается сетевой запрос.

  2. Смотрим в найденные ранее библиотеки для JSON-RPC в поисках вдохновения для парсинга JSON и как они делают то, что хотим мы.

  3. В OkHttp подглядим реализацию Interceptor: они нам точно пригодятся.

Приступим с самого начала. А там — рефлексия. Что же, придётся разбираться.

Рефлексия в Retrofit

Вспоминаем, что такое рефлексия. Рефлексия (от позднелат. reflexio — обращение назад) — это механизм исследования данных о программе во время её выполнения. Рефлексия позволяет исследовать информацию о полях, методах и конструкторах классов. Если заглянуть в исходники Retrofit, то обнаружится, что именно с помощью рефлексии осуществляется вся магия (хотя я почему-то когда-то давно думал, что там кодогенерация). Вспомним, как выглядит использование Retorfit:

  1. Создаём интерфейс, описывающий запросы в сеть. Например UserApi. Методы и аргументы методов помечаем аннотациями.

  2. Создаём экземпляр класса, делающего запросы в сеть — OkHttpClient.

  3. С его помощью делаем экземпляр класса, создающего реализации интерфейса из п.1.

Как же, собственно, создаётся экземпляр класса, реализующего наш интерфейс? Для этого используется класс java.lang.reflect.Proxy. Он позволяет динамически, в runtime, создавать экземпляры классов, реализующих один или несколько интерфейсов. Для создания экземпляра Proxy требуется передать ему реализацию интерфейса java.lang.reflect.InvocationHandler, который предельно прост — всего один метод invoke. Именно в этом методе и происходит вся магия: он имеет всю информацию о вызываемом методе проксируемого интерфейса (имя, тип возвращаемого значения, аннотации etc) и все его аргументы, т. е. всё, что нужно, чтобы совершить те действия, которые нам требуются.

Таким образом, когда мы используем Retrofit, мы делегируем выполнение метода Proxy классу, а он направляет его InvocationHandler-у. Тот, наконец, передаёт вызов классу, который по значениям из аннотаций над методом, его аргументами, параметрами самого Retrofit и с помощью переданного ранее OkHttpClient сделает сетевой запрос.

Реализуем JSON-RPC

Вот мы и добрались до написания своего кода. Сделаем следующее:

  1. Интерфейс JsonRpcClient с методом отправки запроса — принимаем JsonRpcRequest возвращаем JsonRpcResponse.

  2. Реализуем интерфейс. Для реализации нам понадобятся:

    • адрес сервера;

    • OkHttpClient;

    • сериализатор параметров запроса;

    • десериализатор ответа сервера.

  3. Реализуем InvocationHandler, а в нём:

    • сформируем JsonRpcRequest по информации, полученной с помощью рефлексии из вызываемого метода;

    • осуществим сетевой вызов с помощью JsonRpcClient;

    • десериализуем и вернём требуемые данные в случае успеха и прокинем ошибку в случае неудачи.

  4. Соединяем всё вместе.

  1. JsonRpcClient и описание JSON запроса и ответа

data class JsonRpcRequest(

    val id: Long,

    val method: String,

    val params: Map = 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

}
  1. 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,

            )

        }

    }

}
  1. Реализуем InvocationHandler. Чтобы получать информацию из аннотаций, не забываем аннотацию объявить. А также добавим единый источник значений для параметра ID запроса:

annotation class JsonRpc(val value: String)

 

val requestId = AtomicLong(0)

private fun  createInvocationHandler(

    service: Class,

    client: JsonRpcClient,

    resultParser: ResultParser,

): InvocationHandler {

    return object : InvocationHandler {

        override fun invoke(proxy: Any, method: Method, args: Array?): 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(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?, service: Class<*>): Map {

    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 }

}
  1. Соединяем всё вместе, создавая прокси.

fun  createJsonRpcService(

    service: Class,

    client: JsonRpcClient,

    resultParser: ResultParser,

): T {

    val classLoader = service.classLoader

    val interfaces = arrayOf>(service)

    val invocationHandler = createInvocationHandler(

        service,

        client,

        resultParser,

    )

    return Proxy.newProxyInstance(classLoader, interfaces, invocationHandler) as T

}

Теперь у нас всё готово, можем использовать.

  1. Объявим интерфейс, пометим аннотациями:

interface UserApi {

    @JsonRpc("getUser")

    fun getUser(@JsonRpc("id") id: Int): User

}
  1. Создадим его экземпляр через 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()

    )

}
  1. Запустим запрос через, например, корутины:

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.

017443eb9548da2272e15c3bd1549125.png

Если вы когда-то использовали перехватчики в OkHttp, то такой код будет вам знаком:

object : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {

        val request = chain

            .request()

            .newBuilder()

            .header(

                "Authorization",

                "tokenValue"

            )

            .build()

        return chain.proceed(request)

    }

}

Вот как оно работает:

  1. Добавляются 2 интерфейса: Chain и Interceptor.

    • Chain имеет метод proceed, принимающий запрос к серверу и возвращающий ответ сервера.

    • Interceptor имеет метод intercept, принимающий Chain и возвращающий ответ сервера.

  2. Chain имеет реализацию RealInterceptorChain, принимающую в себя список Interceptor-ов и имеющую счётчик, по которому определяется, какой из цепочки Interceptor-ов следует вызывать.

  3. С помощью счётчика происходит рекурсивный вызов Interceptor-ов.

  4. В конец списка всех Interceptor-ов добавляется Interceptor, который непосредственно делает запрос на сервер, получая ответ оного.

  5. В InvocationHandler вместо прямого вызова сервера создаётся RealInterceptorChain, в который передаются наши пользовательские Interceptor-ы в нужном нам порядке и вызывается метод intercept первого Interceptor-а в цепочке.

В итоге Interceptor-ы вызывают друг друга рекурсивно, пока не дойдут до последнего Interceptor-а, который вызовет сервера, после чего ответ сервера будет по цепочке возвращён к самому первому Interceptor-у. Таким образом, мы можем как модифицировать запрос, который по цепочке передаётся от одного Interceptor-а к другому, так и как-то отреагировать на ответ сервера.

Будем считать, что абстракция нам понятна, и попробуем прописать детали реализации.

Реализуем свои Interceptor-ы

  1. Объявим интерфейс для цепочки:

interface Chain {

    fun proceed(request: JsonRpcRequest): JsonRpcResponse

    fun request(): JsonRpcRequest

}
  1. Объявим интерфейс для перехватчика:

interface JsonRpcInterceptor {

    fun intercept(chain: Chain): JsonRpcResponse

}
  1. Реализуем Chain.

data class RealInterceptorChain(

    private val client: JsonRpcClient,

    val interceptors: List,

    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

}
  1. Реализуем перехватчик, который будет делать запрос на сервер.

class ServerCallInterceptor(private val client: JsonRpcClient) : JsonRpcInterceptor {

    override fun intercept(chain: JsonRpcInterceptor.Chain): JsonRpcResponse {

        return client.call(chain.request())

    }

}

Теперь в нашем InvocationHandler используем RealInterceptorChain, добавив возможность передавать Interceptor-ы при создании прокси-класса:

fun  createJsonRpcService(

    ...

    interceptors: List = listOf()

) : T {

    ...

    val invocationHandler = createInvocationHandler(

        ...

        interceptors

    )

    ...

}

private fun  createInvocationHandler(

    ...

    interceptors: List = listOf()

): InvocationHandler {

    return object : InvocationHandler {

        override fun invoke(proxy: Any, method: Method, args: Array?): 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

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

© Habrahabr.ru