Привет, Хабр! Меня зовут Юра Кучанов @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
Вот так, столкнувшись с интересной задачей, можно, основываясь на проектах с открытым исходным кодом, её успешно решить. Не бойтесь писать свой велосипед, если уже имеющиеся решения обладают хотя бы одним фатальным недостатком!
Rsa97
Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text.
The JSON sent is not a valid Request object.
The method does not exist / is not available.
Invalid method parameter(s).
Internal JSON-RPC error.
Reserved for implementation-defined server-errors.
kuchanov
Всё верно. Стандартные ошибки, конечно есть. В тексте имелось в виду, скорее, что-то вроде аналогов 403 HTTP кода. Т.е. в случае использования JSON-RPC мы не ограничены высеченным в граните списком кодов ошибок и сами составляем нужный нам список с нужными нам значениями. Например, вместо 400 кода с деталями того, что пошло не так в теле ответа, мы можем завести отдельный код на каждый конкретный случай (невалидный email, невалидная сумма etc)