Распределённые приложения на C++ с минимумом усилий

    Цель моего поста — рассказать о C++ API распределенной базы данных Apache Ignite, который называется Ignite C++, а также о его особенностях.


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


    Кратко об Apache Ignite для тех, кто пока с ним не знаком


    Не буду вдаваться в подробности о том, как появился Apache Ignite и чем отличается от классических баз данных. Все эти вопросы уже поднимались тут, тут или тут.


    Итак, Apache Ignite — это по сути быстрая распределённая база данных, оптимизированная для работы с оперативной памятью. Сам Ignite вырос из дата грида (In-memory Data Grid) и до недавнего времени позиционировался как очень быстрый, находящийся полностью в оперативной памяти распределённый кэш на основе распределенной хэш-таблицы. Вот почему, кроме хранения данных, в нем есть множество удобных фич для их быстрой распределенной обработки: Map-Reduce, атомарные операции с данными, полноценные ACID транзакции, SQL запросы по данным, так называемые Continues Queries, дающие возможность следить за изменением определённых данных и другие.


    Однако недавно в платформе появилась поддержка постоянного хранилища данных на диске. После чего Apache Ignite получил все преимущества полноценной объектно ориентированной базы данных, сохранив при этом удобство, богатство инструментария, гибкость и быстроту дата грида.


    Немного теории


    Важной деталью для понимания работы с Apache Ignite является то, что он написан на Java. Вы спросите: «Какая мне разница, на чём написана база данных, если я с ней в любом случае буду общаться посредством SQL?». В этом есть доля истины. Если вы хотите использовать Ignite только как базу данных, вы вполне можете взять ODBC или JDBC драйвер, поставляющийся вместе с Ignite, поднять необходимое вам количество серверных нод с помощью специально для этого созданного скрипта ignite.sh, настроить их с помощью гибких конфигов и особо не парится по поводу языка, работая с Ignite хоть из PHP, хоть из Go.


    Нативный интерфейс Ignite даёт намного больше возможностей, чем просто SQL. Из самого простого: быстрые атомарные операции с объектами в базе, распределенные объекты синхронизации и распределенные вычисления в кластере на локальных данных, когда вам не надо вытаскивать сотни мегабайт данных на клиент для вычислений. Как вы понимаете, эта часть API работает не через SQL, а написана на вполне конкретных языках программирования общего назначения.


    Естественно, так как Ignite написан на Java, самый полный API реализован именно на этом языке программирования. Однако, кроме Java, существуют также версии API для C# .NET и C++. Это так называемые «толстые» клиенты — по сути, Ignite нода в JVM, запущенная из C++ или C#, общение с которой происходит посредством JNI. Этот вид нод необходим, кроме всего прочего, для того, чтобы на кластере можно было запускать распределённые вычисления на соответствующих языках — C++ и C#.


    Кроме того, существует открытый протокол для так называемых «тонких» клиентов. Это уже лёгкие библиотеки на различных языках программирования, общающиеся с кластером через TCP/IP. Они занимают намного меньше места в памяти, стартуют почти мгновенно, не требуют наличия JVM на машине, но зато обладают несколько худшими показателями по latency и не настолько богатым API по сравнению с «толстыми» клиентами. На сегодняшний день существуют тонкие клиенты на Java, C#, и Node.js, активно разрабатываются клиенты на C++, PHP, Python3, Go.


    В посте я рассмотрю API «толстого» интерфейса Ignite для C++, так как именно он в данный момент предоставляет наиболее полный API.


    Начало работы


    Я не буду подробно останавливаться на процессе установки и настройки самого фреймворка — процесс рутинный, не особо интересный и неплохо описан например в официальной документации. Перейдём сразу к коду.


    Так как Apache Ignite — платформа распределённая, то для начала работы первым делом надо запустить хотя бы одну ноду. Делается это очень просто с помощью класса ignite::Ignition:


    #include <iostream>
    #include <ignite/ignition.h>
    
    using namespace ignite;
    
    int main()
    {
        IgniteConfiguration cfg;
    
        Ignite node = Ignition::Start(cfg);
    
        std::cout << "Node started. Press 'Enter' to stop" << std::endl;
        std::cin.get();
    
        Ignition::StopAll(false);
    
        std::cout << "Node stopped" << std::endl;
    
        return 0;
    }

    Поздравляю, вы запустили свою первую ноду Apache Ignite на C++ с настройками по умолчанию. Класс Ignite, в свою очередь, является основной точкой входа для получения доступа ко всему API кластера.


    Работа с данными


    Главный компонент Ignite C++, который предоставляет API для работы с данными — это кэш, ignite::cache::Cache<K,V>. Кэш даёт базовый набор методов для работы с данными. Так как Cache по сути — интерфейс к распределённой хэш-таблице, базовые методы работы с ним напоминают работу с обычными контейнерами типа map или unordered_map.


    #include <string>
    #include <cassert>
    #include <cstdint>
    
    #include <ignite/ignition.h>
    
    using namespace ignite;
    
    struct Person
    {
        int32_t age;
        std::string firstName;
        std::string lastName;
    }
    
    //...
    
    int main()
    {
        IgniteConfiguration cfg;
    
        Ignite node = Ignition::Start(cfg);
    
        cache::Cache<int32_t, Person> personCache = 
            node.CreateCache<int32_t, Person>("PersonCache");
    
        Person p1 = { 35, "John", "Smith" };
    
        personCache.Put(42, p1);
    
        Person p2 = personCache.Get(42);
    
        std::cout << p2 << std::endl;
    
        assert(p1 == p2);
    
        return 0;
    }

    Выглядит довольно просто, не так ли? На самом деле, всё несколько усложняется, если подробнее рассмотреть ограничения C++.


    Сложности интеграции с C++


    Как я уже упоминал, Apache Ignite полностью написан на Java — мощном управляемом ООП языке. Закономерно, что многие возможности этого языка, связанные, например, с рефлексией времени исполнения программы, активно использовались для реализации компонентов Apache Ignite. Например, для сериализации/десериализации объектов для хранения на диске и передачи по сети.


    В C++, в отличие от Java, такой мощной рефлексии нет. Вообще никакой нет пока, к сожалению. В частности, нет способов узнать список и тип полей объекта, что могло бы позволить автоматически генерировать код, необходимый для сериализации/десериализации объектов пользовательских типов. Поэтому единственный вариант здесь — попросить пользователя явно предоставить необходимый набор метаданных о пользовательском типе и способе работы с ним.


    В Ignite C++ это реализовано через специализацию шаблона ignite::binary::BinaryType<T>. Этот подход используется как в «толстом», так и в «тонком» клиентах. Для класса Person, представленного выше, подобная специализация может выглядеть следующим образом:


    namespace ignite
    {
    namespace binary
    {
    
    template<>
    struct BinaryType<Person>
    {
        static int32_t GetTypeId()
        {
            return GetBinaryStringHashCode("Person");
        }
    
        static void GetTypeName(std::string& name)
        {
            name = "Person";
        }
    
        static int32_t GetFieldId(const char* name)
        {
            return GetBinaryStringHashCode(name);
        }
    
        static bool IsNull(const Person& obj)
        {
            return false;
        }
    
        static void GetNull(Person& dst)
        {
            dst = Person();
        }
    
        static void Write(BinaryWriter& writer, const Person& obj)
        {
            writer.WriteInt32("age", obj.age;
            writer.WriteString("firstName", obj.firstName);
            writer.WriteString("lastName", obj.lastName);
        }
    
        static void Read(BinaryReader& reader, Person& dst)
        {
            dst.age = reader.ReadInt32("age");
            dst.firstName = reader.ReadString("firstName");
            dst.lastName = reader.ReadString("lastName");
        }
    };
    
    } // namespace binary
    } // namespace ignite

    Как можно видеть, кроме методов сериализации/десериализации BinaryType<Person>::Write, BinaryType<Person>::Read, здесь присутствует ещё несколько других методов. Они нужны для того, чтобы объяснить платформе, как работать с пользовательскими типами C++ на других языках, в частности, Java. Давайте рассмотрим детальнее каждый метод:


    • GetTypeName() — Возвращает имя типа. Имя типа должно быть одинаковым на всех платформах, на которых данный тип используется. Если вы используете тип только в Ignite C++, имя может быть любым.
    • GetTypeId() — Этот метод возвращает кросс-платформенный уникальный идентификатор для типа. Для корректной работы с типом на разных платформах надо, чтобы он везде вычислялся одинаково. Метод GetBinaryStringHashCode(TypeName) возвращает такой же Type ID, как и на всех остальных платформах по умолчанию, то есть такая реализация этого метода позволяет корректно работать с данным типом из других платформ.
    • GetFieldId() — Возвращает уникальный идентификатор для имени типа. Опять же, для корректной кросс-платформенной работы стоит использовать метод GetBinaryStringHashCode();
    • IsNull() — Проверяет, является ли экземпляр класса объектом типа NULL. Используется для корректной сериализации NULL-значений. Не очень полезен с экземплярами самого класса, но может быть крайне удобен, если пользователь хочет работать с умными указателями и определить специализацию, например, для BinaryType< std::unique_ptr<Person> >.
    • GetNull() — Вызывается при попытке десериализовать NULL значение. Всё, сказанное про IsNull, справедливо и для GetNull().

    SQL


    Если проводить аналогию с классическими базами данных, то кэш является схемой базы данных с именем класса, содержащей одну таблицу — с именем типа. Кроме схем-кэшей существует общая схема с именем PUBLIC, в которой можно создавать/удалять неограниченное количество таблиц с использованием стандартных DDL команд, таких как CREATE TABLE, DROP TABLE и так далее. Именно к схеме PUBLIC обычно подключаются через ODBC/JDBC, если хотят использовать Ignite просто как распределённую базу данных.


    Ignite поддерживает полноценные SQL запросы, включая DML и DDL. Поддержки SQL-транзакций пока нет, но сообщество сейчас активно работает над внедрением MVCC, что позволит добавить транзакции, и, насколько мне известно, основные изменения были недавно влиты в master.


    Для работы с данными кэша через SQL необходимо в конфигурации кэша явно указать, какие поля объекта будут использоваться в SQL запросах. Конфигурация прописывается в XML-файле, после чего путь к файлу конфигурации указывается при запуске ноды:


    <beans xmlns="http://www.springframework.org/schema/beans"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xmlns:util="http://www.springframework.org/schema/util"
           xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/util
        http://www.springframework.org/schema/util/spring-util.xsd">
    
      <bean id="grid.cfg" class="org.apache.ignite.configuration.IgniteConfiguration">
    
        <property name="cacheConfiguration">
          <list>
            <bean class="org.apache.ignite.configuration.CacheConfiguration">
              <property name="name" value="PersonCache"/>
    
              <property name="queryEntities">
                <list>
                  <bean class="org.apache.ignite.cache.QueryEntity">
                    <property name="keyType" value="java.lang.Integer"/>
                    <property name="valueType" value="Person"/>
    
                    <property name="fields">
                      <map>
                        <entry key="age" value="java.lang.Integer"/>
                        <entry key="firstName" value="java.lang.String"/>
                        <entry key="lastName" value="java.lang.String"/>
                      </map>
                    </property>
                  </bean>
                </list>
              </property>
            </bean>
          </list>
        </property>
      </bean>
    </beans>

    Конфиг парсится Java-движком, поэтому базовые типы должны быть указаны также для Java. После того, как конфигурационный файл создан, надо стартовать ноду, получить экземпляр кэша и можно начинать использовать SQL:


    //...
    
    int main()
    {
        IgniteConfiguration cfg;
        cfg.springCfgPath = "config.xml";
    
        Ignite node = Ignition::Start(cfg);
    
        cache::Cache<int32_t, Person> personCache =
            node.GetCache<int32_t, Person>("PersonCache");
    
        personCache.Put(1, Person(35, "John", "Smith"));
        personCache.Put(2, Person(31, "Jane", "Doe"));
        personCache.Put(3, Person(12, "Harry", "Potter"));
        personCache.Put(4, Person(12, "Ronald", "Weasley"));
    
        cache::query::SqlFieldsQuery qry(
            "select firstName, lastName from Person where age = ?");
    
        qry.AddArgument<int32_t>(12);
    
        cache::query::QueryFieldsCursor cursor = cache.Query(qry);
    
        while (cursor.HasNext())
        {
            QueryFieldsRow row = cursor.GetNext();
    
            std::cout << row.GetNext<std::string>() << ", ";
            std::cout << row.GetNext<std::string>() << std::endl;
        }
    
        return 0;
    }

    Таким же образом можно использовать insert, update, create table и прочие запросы. Само собой, поддерживаются также и cross-cache запросы. Правда, в таком случае имя кэша следует указывать в запросе в кавычках как имя схемы. Например, вместо


    select * from Person inner join Profession

    следует писать


    select * from "PersonCache".Person inner join "ProfessionCache".Profession

    И так далее


    Возможностей в Apache Ignite действительно много и, конечно, в одном посте невозможно было охватить их все. C++ API сейчас активно развивается, так что скоро интересного будет ещё больше. Вполне возможно, что я напишу ещё несколько постов, где разберу какие-то фичи более подробно.


    P.S. Я коммитер Apache Ignite с 2017 года и активно разрабатываю C++ API для этого продукта. Если вы сносно знаете C++, Java или .NET и хотели бы поучаствовать в разработке открытого продукта с активным приветливым сообществом, у нас всегда найдётся парочка-другая интересных задач для вас.

    • +15
    • 5,2k
    • 4
    GridGain
    100,00
    Компания
    Поделиться публикацией

    Комментарии 4

      +2
      Я далёк от многих модных тем вроде BigData или ML, но я довольно много занимался веб-разработкой, и с моей точки зрения единая масштабируемая платформа для хранения SQL-таблиц и различных кэшей (сессий, страниц, запросов) была бы просто манной небесной для высоконагруженных сайтов. По крайней мере для тех, что построены по традиционной монолитной архитектуре − точно.

      Нарушает идиллию только одно: в Ignite SQL нет автоинкрементных типов. Я понимаю, что это непростая задача с учётом распределённости, но я думаю, её нужно решить как можно скорее, хотя бы в каком-то ограниченном виде.
        +1

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

          +1
          Ну, значит, теперь дело за малым: сделать эту фичу доступной в ODBC и Thin-клиентах.
          0
          Не уверен, что автоинкрементальные колонки появятся в продукте в обозримом будущем. Там очень много проблем. Гораздо лучше сразу привыкать к особенностям распределенных систем, и использовать глобально уникальные значения, типа UUID.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое