Protobuf vs Reflection

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Вы когда-нибудь задумывались, насколько grpc быстрый. Да, в сети, ему равных нет. Если вы гоняете маленькие сообщения, которые надо быстро доставить, то лучше grpc попросту не найти. Но насколько он хорош? Сможет ли он к примеру сравнится просто с нативными вызовами?

Попробуем сравнить это, но так как в обычной жизни нам это может не пригодится, то добавим еще одно условие - сравниваем как лучший способ взаимодействия с jni библиотекой.

Постановка задачи

С давних времен рефлексия в jvm считается чем очень долгим и затратным процессом, каждый уважаемый себя разработчик на любом ревью мог попинать джуна - что за кошмарный код он использует через рефлексию. Сейчас времена другие, теперь джун стал умнее, но необходимость от использования рефлексии никуда не отпала.

К примеру, если вы пишете ndk приложения, то почти со 100% вероятностью вам понадобится использовать этот подход, чтобы просто смаппить данные из jvm в классические C++ классы или структуры. И да, этот маппинг не для слабонервных.

JNIEXPORT jint JNICALL
Java_com_example_engine_JniEngine_cmd(JNIEnv *env, jobject, jobject jCmd) {
    struct some_cmd cmd{};

    jfieldID idField = env->GetFieldID(env->FindClass("com/example/model/SomeCmd"), "id", "I");
    jfieldID nameField = env->GetFieldID(env->FindClass("com/example/model/SomeCmd"), "name", "Ljava/lang/String;");

    cmd.id = env->GetIntField(jCmd, idField);
    
    jstring jName = (jstring) env->GetObjectField(jCmd, nameField);
    const char *name = jName != NULL ? env->GetStringUTFChars(jName, NULL) : NULL;
    cmd.name = std::string(name ?: "");
    if (name != NULL) env->ReleaseStringUTFChars(jName, name);
}

Примерно так нужно маппить данные из jvm в нативной библиотеке ndk. И как сразу много проблем случается, если к примеру кто-то решит перенести модельку SomeCmd из одного пакета в другой. Ну или переименовать поле или метод.

Каждый такой маппинг может использовать рефлексию один или несколько раз.

jfieldID idField = env->GetFieldID(env->FindClass("com/example/model/SomeCmd"), "id", "I");
jfieldID nameField = env->GetFieldID(env->FindClass("com/example/model/SomeCmd"), "name", "Ljava/lang/String;");

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

Просто Proto

Выбрав путь война, тестировать планируем все на своем компьютере. А так как монструозных плагинов, поддерживающих и cmake и java, не много, автор может упомянуть только AGP, и он нам не подходит из-за специфичности платформы, то выбираем свой путь. Выполняем сборку проекта из нескольких плагинов для cmake, java, а также protobuf.

plugins {
    application
    id("com.github.gmazzo.buildconfig") version ("3.1.0")
    id("io.github.tomtzook.gradle-cmake") version ("1.2.2")
    id("com.google.protobuf") version ("0.9.3")
}

Используем классическую реализацию protobuf без grpc и kroto плагинов. Сам тест при этом ничего делает, только пересылает простую модельку в нативную библиотеку и обратно. Модельку сделали вложенной и из различных типов данных, чтобы приблизиться к реальному использованию.

syntax = "proto3";

option java_package = "com.github.klee0kai.proto";

message SomeCmdModel {
  int32 id = 1;
  int64 count = 2;
  float value = 3;
  double valueD = 4;
  string name = 5;
  repeated MetaModel meta = 6;
}

Для cmake проекта дополнительно нужно установить библиотеки protoc, к примеру для ubuntu.

apt-get install libprotobuf-dev protobuf-compiler

Генерируем модели C++ из proto файлов и подтягиваем библиотеку в CMakeLists.txt.

find_package(Protobuf REQUIRED)
include_directories(${Protobuf_INCLUDE_DIRS})
include_directories(${CMAKE_CURRENT_BINARY_DIR})
protobuf_generate_cpp(PROTO_SRCS PROTO_HDRS ./../proto/jni.proto)

target_link_libraries(myapplication ${Protobuf_LIBRARIES})

target_include_directories(
        myapplication
        PUBLIC
        ${JNI_INCLUDE_DIRS}
        ${PROTO_HDRS}

        ./../../../build/generated/sources/headers/java/main
)

Сами хедер файлы генерируются protobuf плагином в gradle, а работа с protobuf в C++ будет уже работать за счет, найденной в cmake, библиотеки. Проверяйте версии используемых библиотек и там и там, так как найденная в cmake библиотека protobuf может не переварить сгенерированные хедер файлы либо не поддержать различные опции, как например lite.

Так как в нашем проекте использованы несколько самостоятельных плагинов, никак не связанных между собой, немного корректируем последовательность выполнения задач в gradle. Сборку cmake будем выполнять после java - это нужно, чтобы сгенерировались jni хедер файлы для C++, а также сгенерировались хедер файлы proto моделек, которые в нашем случае генерируются через gradle.

tasks.clean.dependsOn(tasks.cmakeClean)
tasks.classes.dependsOn(tasks.cmakeBuild)
tasks.cmakeBuild.dependsOn(tasks.compileJava)

Сам проект, кстати, написан на java. Jni интеграция для kotlin выглядит сложнее. Так, например, многие kotlin типы в jni не доступны по своим именам классов:kotlin.Int представляется либо через примитивный тип int, либо через его объектное представление java.lang.Integer. Kotlin не генерирует хедер файлы для jni, что в java поддерживается по умолчанию. Вдобавок, kotlin может исказить итоговые имена полей и методов.

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

:term:run
\--- :term:classes
     +--- :term:cmakeBuild
     |    +--- :term:compileJava
     |    |    +--- :term:generateBuildConfig
     |    |    \--- :term:generateProto
     |    |         +--- :term:extractIncludeProto
     |    |         \--- :term:extractProto
     |    \--- :term:myapplication_linux-amd64_runGeneratorUnix_Makefiles
     |         \--- :term:cmakemyapplication_linux-amd64
     +--- :term:compileJava
     |    +--- ***
     \--- :term:processResources
          \--- :term:extractProto
               \--- ***

Осталось собрать и прогнать тест.

./gradlew run

Сухой остаток

Прогнав тест получаем результат

> Task :term:run
test jni reflection test...........
Test time 1.272
test on indexed jni reflection...........
Test time 0.594
test proto serialize...........
Test time 1.034

Protobuf получился быстрее рефлексии, однако не является лучшим решением для работы с jni. Самым оптимальным получилось решение использовать рефлексию с предварительным индексированием классов и методов - что заняло по итогу около 0.594 с. работы для 100к операции копирования и пересылки моделек в jni библиотеку и обратно.

Так как автор не провел замеры скорости работы нативной работы java, то предположим, что проиндексированная рефлексия является наиболее ее приближенным вариантом. Потери при использовании protobuf будут x2 от нативной работы, а при рефлексии - около x2.2. Подробнее о работе смотри на github.

Источник: https://habr.com/ru/articles/761474/


Интересные статьи

Интересные статьи

Привет! Я Team Lead в Scalable Solutions. Мы с командой давно работаем над нашей платформой и уже дошли до той точки, когда любые технические решения должны быть обоснованы и согласованы с коллегами. ...
Protobuf достаточно распространённый протокол сериализации структурированных данных, однако для многих не секрет, что запуск чего-либо на плюсах бывает сопряжено с испытаниями, если ты новичок. Поэтом...
Продолжаю делать пилить свой petproject. Что нового с прошлой публикацией: запись; сообщений в кафку; создание/удаление топиков; бинарные сборки для OSX и Windows.Сейчас подошел к тому ради чего все э...
Что значит значение равно null?Проблема в том, что null может обозначать разные вещи в разных контекстах: - Null — это null. - Null — значение опционально / не установлен...
Недавно вышло третье издание книги "Effective Java" («Java: эффективное программирование»), и мне было интересно, что появилось нового в этой классической книге по Java, ...