Pull to refresh

Операции как объекты

Reading time 11 min
Views 17K
Не так давно мне пришлось обращаться к хранилищу ZooKeeper из кода на C++. Приличной С++-обёртки для сишной библиотеки libzookeeper не нашлось, поэтому её пришлось написать самому. В процессе реализации я существенно видоизменил подход авторов java-библиотеки к построению API и теперь хочу поделиться с вами причинами и результатами принятых решений. Несмотря на ключевые слова С++ и ZooKeeper, подход, описанный в статье, подходит для организации доступа к любым хранилищам, и вполне реализуем на языках, отличных от С++.


Введение



ZooKeeper — это отказоустойчивая распределённая база данных, представляющая данные в виде иерархического набора узлов. Узлы можно создавать, модифицировать, удалять, проверять их существование, управлять правами доступа к ним. Некоторые операции принимают дополнительные опции, например, можно указать версию узла, на которую распространяется действие команды. Мультипоточный клиент ZooKeeper, о котором идёт речь в этой статье, создаёт два дополнительных потока — в одном он выполняет все I/O операции, в другом выполняет пользовательские коллбэки и мониторы. Мониторы — это функции, которые вызываются клиентом при изменении состояния узла. Например, можно узнать, существует ли узел, и передать функцию, которая будет вызвана, когда узел пропадёт или появится. Остальные детали, необходимые для понимания статьи, я буду приводить по мере необходимости.
Нам ZooKeeper понадобился для координации выполнения задач множеством машин в нескольких датацентрах.

Начав работать над C++-библиотекой, я решил сделать API максимально близким к API Java-клиента, имеющего один большой класс ZooKeeper, предоставляющий по одному методу для каждой операции над узлами. Однако, довольно быстро обнаружились недостатки этого подхода.

Мне хотелось иметь несколько вариантов выполнения для каждой команды:

  • Стандартный асинхронный: мы передаём клиенту параметры запроса и функцию обратного вызова (коллбэк). Когда операция завершится, клиент вызовет предоставленную функцию в отдельном потоке, созданном клиентом ZooKeeper.
  • Асинхронный, возвращающий объект std::future. Мы передаём клиенту параметры запроса, клиент возвращает объект, представляющий асинхронное вычисление. Когда мы вызовем метод std::future::get, нам вернут управление после завершения выполнения операции. Если операция завершилась ошибкой, вызов std::future::get должен выбросить исключение.
  • Синхронный. Вызов блокируется, пока операция не будет завершена. Ошибки транслируются в исключения.


Если операция имеет N возможных опций (с монитором / без монитора, с версией / без версии и т.п.) и M вариантов исполнения, нас ждёт написание и поддержка N * M методов. Например, в java-клиенте есть 4 метода exists:

Stat exists(String path, boolean watch)
void exists(String path, boolean watch,
            AsyncCallback.StatCallback cb, Object ctx)
Stat exists(String path, Watcher watcher)
void exists(String path, Watcher watcher,
            AsyncCallback.StatCallback cb, Object ctx)


Если захочется иметь вариант, возвращающий future, придётся добавить ещё 2 метода. Итого 6 методов, и это только для одной операции! Я посчитал это неприемлемым.

Типы спешат на помощь



После осознания бесперспективности очевидного пути, мне пришла идея реструктуризации API — нужно максимально отделить способ выполнения команды от самой команды. Каждую команду нужно оформить в виде отдельного типа — контейнера параметров.

В клиенте в таком случае нужно реализовать только один метод для асинхронного выполнения команд:

void run(Command cmd, Callback callback);


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

Итак, для каждой операции заведём отдельный класс:
  • CreateCmd
  • DeleteCmd
  • ExistsCmd


Каждый класс будет хранить все параметры, которые необходимы для выполнения команды. Например, операция delete принимает обязательный путь и может опционально принимать версию данных, к которым применима. Операция exists также требует путь и может опционально принимать функцию, вызываемую при удалении/создании нода.

Тут уже можно выделить некоторые шаблоны — например, все команды должны содержать путь к ноду, некоторые могут применяться к конкретной версии (delete, setACL, setData), некоторые принимают дополнительный коллбэк-монитор или могут публиковать события в сессионный коллбэк-монитор. Можно риализовать эти «шаблоны» в виде примесей (mixins), из которых мы, как из кирпичиков, будем собирать наши команды. Всего мне удалось разглядеть 3 примеси:
  • Pathable — принимает путь в конструкторе и предоставляет метод для получения пути.
  • Versionable — хранит версию и предоставляет методы для указания версии и получения указанной версии.
  • Watchable — хранит и позволяет определить коллбэк, вызываемый при изменении состояния узла.

Для примера приведу код примеси Versionable:

template <typename SelfType>
struct Versionable {
    explicit Versionable(Version version = AnyVersion)
        : version_(version) {}

    SelfType & setVersion(Version version) {
        this->version_ = version;
        return static_cast<SelfType &>(*this);
    }

    Version version() const { return this->version_; }

private:
    Version version_;
};


Для того, чтобы setVersion возвращал тип базового класса Versionable, здесь используется техника curiously recurring template pattern. Добавление примесей в команды выглядит следующим образом:

struct DeleteCmd : Pathable, Versionable<DeleteCmd> {
    explicit DeleteCmd(std::string path);
    // other methods
 };


Следущий шаг — определить, какой тип коллбэка должен соответствовать каждой из команд, ведь при завершении работы разных команд в коллбэки передаются значения разных типов. Всего можно выделить 7 типов коллбэков:

using VoidCallback =
    std::function<void(std::error_code const&)>;

using StatCallback =
    std::function<void(std::error_code const&, Stat const&)>;

using ExistsCallback =
    std::function<void(std::error_code const&, bool, Stat const&)>;

using StringCallback =
    std::function<void(std::error_code const&, std::string)>;

using ChildrenCallback =
    std::function<void(std::error_code const&, Children, Stat const&)>;

using DataCallback =
    std::function<void(std::error_code const&, std::string, Stat const&)>;

using AclCallback =
    std::function<void(std::error_code const&, AclVector, Stat const&)>;


Что такое Stat?
Структура Stat содержит метаинформацию об узле дерева, наподобие структуры stat в UNIX-системах. Например, в этой структуре содержится виртуальное время последней модификации, размер данных, хранящихся в узле, количество потомков и т.п.


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

Далее, нужно написать код, который будет выполнять наши команды асинхронно. Самый простой вариант — возложить ответственность за упаковку параметров и неблокирующий запуск команд на сами классы команд. Это тоже немного противоречит принятой философии, однако позволяет держать всю логику асинхронной обработки команд в одном месте. Если следующая версия ZooKeeper будет содержать новую команду, достаточно будет добавить в нашу библиотеку всего один класс, изменения будут очень локальными и обратно совместимыми.

Для единства интерфейса команд я решил ввести абстрактный тип Handle — низкоуровневый дескриптор, скрывающий от клиента библиотеки все детали реализации (например, тот факт, что для исполнения команд используется библиотека libzookeeper). В C/C++ этого можно достичь, объявив тип, но не определив его в публичных заголовочных файлах библиотеки:
class Handle;

Как именно реализован класс Handle не так уж и важно. Для простоты можно предположить, что это на самом деле это zhandle_t из библиотеки libzookeeper, и реализация наших команд в тайне от пользователя преобразует указатель на наш неполный тип в указатель на zhandle_t.

Таким образом, в каждом классе, представляющем команду, появляется перегруженный оператор вызова

struct SomeCmd {
    using CallbackType = SomeCallbackType;

    void operator()(Handle *, CallbackType) const;
};


Я не буду приводить код упаковки параметров команд, т.к. он довольно громоздкий и содержит много промежуточного кода и деталей, не относящихся к сути статьи.

Метод запуска команд в классе клиента становится совсем простым:

class Session {
public:
    // other methods
    template <typename CmdType>
    void run(CmdType const& cmd, typename CmdType::CallbackType callback) {
        cmd(this->getHandle(), std::move(callback));
    }
private:
    Handle * getHandle();
};


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

Важно, что перегруженный оператор вызова является константным — команды не должны изменять своё состояние при вызове. Во-первых, это позволит использовать временные объекты команд в коде следующего вида:

session.run(DeleteCmd("path").setVersion(knownVersion), myCallback);


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

Во-вторых, так мы сообщаем человеку, читающему код (и, отчасти, компилятору), что повторное выполнение одной и той же команды не должно приведить к побочным эффектам, не относящимся к изменению структуры хранилища.

Теперь мы можем асинхронно выполнять все операции со всеми возможными опциями, и для этого в клиенте нам нужен только один метод — run.

Добавляем асинхронное выполнение команд c std::future



Итак, теперь настала очередь реализовать то, ради чего всё и затевалось — альтернативные варианты выполнения.

Для возможности асинхронного выполнения команд с объектом std::future хочется иметь функцию, обладающую следующей сигнатурой:

template<class CmdType>
std::future<ResultOf<CmdType>> runAsync(Session &, CmdType const&);


Эта функция принимает на вход сессию и команду, возвращая объект std::future, представляющий результат асинхронного выполнения команды.

Для начала нужно понять, как уместить параметры коллбэка команды в одно значение. Именно для этого и нужна метафункция ResultOf. Есть несколько способов указать соответствие параметров функции возвращаемым значениям, я выбрал самый простой — просто выписать каждый возможный случай в виде отдельной специализации шаблонного класса DeduceResult.

template <typename CallbackType>
struct DeduceResult;

template <>
struct DeduceResult<std::function<void(std::error_code const&)>> {
    using type = void;
};

template <typename T>
struct DeduceResult<std::function<void(std::error_code const&, T)>> {
    using type = typename std::decay<T>::type;
};

template <typename T>
struct DeduceResult<std::function<void(std::error_code const&,
                                       T,
                                       Stat const&)>> {
   using type = std::pair<typename std::decay<T>::type, Stat>;
};

template <typename CmdType>
using ResultOf = typename DeduceResult<typename CmdType::CallbackType>::type;


Логика работы DeduceResult проста:
  • Если в коллбэк передаётся только код ошибки, то результат операции — void.
  • Если в коллбэк, помимо кода ошибки, передаётся дополнительный параметр, то результатом операции будет этот параметр.
  • Если в коллбэк передаётся ещё и ссылка на объект Stat, результат и объект Stat будут упакованы в std::pair.

ResultOf — это шаблонный синоним (alias template, одна из приятных возможностей C++11), передащий в DeduceResult тип коллбэка, определённый в команде.

Заслуживает внимания использование метафункции std::decay — некоторые параметры передаются в коллбэк по ссылке, но мы хотим вернуть их клиентам по значению, т.к. объекты могут жить на стеке и, если передавать в другой поток ссылки на них, будут уже разрушены к тому моменту, когда клиент будет их читать.

Теперь можно заняться реализацией функции runAsync. Реализация практически очевидна: нужно создать объект std::promise нужного типа, получить из него объект std::future (вызвав метод std::promise::get_future()), сформировать специальный коллбэк, который получит объект std::promise во владение и выставит в него результат или ошибку выполнения коллбэка. Далее нужно просто выполнить команду через стандартный интерфейс сессии с нашим коллбэком. Поскольку владеть объектом promise должен коллбэк, логично сделать колбэк объектом-функцией, содержащим promise в качестве поля. Результирующий код функции runAsync выглядит следующим образом:

template <typename CmdType>
std::future<ResultOf<CmdType>>
runAsync(Session & session, CmdType const& cmd) {
    FutureCallback<ResultOf<CmdType>> cb;
    auto f = cb.getPromise().get_future();
    session.run(cmd, std::move(cb));
    return f;
}


Реализация объекта-функции FutureCallback во многом зеркально отражает логику, вложенную нами в метафункцию ResultOf. Исходя из ожидаемого типа операции, мы генерируем функции, упаковывающие свои входные аргументы в объект, и передающие этот объект в общее (с объектом future) состояние через promise::set_value или promise::set_exception.

template <typename T>
void setError(std::promise>T> & p, std::error_code const& ec) {
    // Опустим реализацию функции codeToExceptionPtr для краткости.
    // Она не делает ничего особенного, просто преобразует значения
    // кодов ошибок в объект exception_ptr, содержащий исключение
    // соответствующего типа.
    p.set_exception(codeToExceptionPtr(ec));
}

template <typename T>
struct CallbackBase {
    // Мы храним умный указатель на объект promise только для того, чтобы
    // соблюсти семантику std::function - функции должны быть копируемыми.
    // См. n337 (20.8.11.2.1)
    using PromisePtr = std::shared_ptr<std::promise<T>>;

    PromisePtr promisePtr;

    CallbackBase() : promisePtr(std::make_shared<std::promise<T>>()) {}

    std::promise<T> & getPromise() { return *promisePtr.get(); }
};

template <typename T>
struct FutureCallback : CallbackBase<T> {
    void operator()(std::error_code const& ec, T value) {
        if (ec) {
            setError(this->getPromise(), ec);
        } else {
            this->getPromise().set_value(std::move(value));
        }
    }
};

template <>
struct FutureCallback<void> : CallbackBase<void> {
    void operator()(std::error_code const& ec) {
        if (ec) {
            setError(this->getPromise(), ec);
        } else {
            this->getPromise().set_value();
        }
    }
};

template >typename T>
struct FutureCallback<std::pair<T, Stat>> : CallbackBase<std::pair<T, Stat>> {
    void operator()(std::error_code const& ec, T data, Stat const& stat) {
        if (ec) {
            setError(this->getPromise(), ec);
        } else {
            this->getPromise().set_value(
                std::make_pair(std::move(data), stat));
        }
    }
};


Теперь мы можем использовать нашу функцию примерно следующим образом:

std::vector<std::future<std::string>> nodeNameFutures;
// Запускаем несколько асинхронных операций
for (const auto & name : {"/node1", "/node2", "/node3"}) {
    nodeNameFutures.emplace_back(runAsync(session, CreateCmd(name)));
}
// Дожидаемся выполнения всех операций
for (auto & f : nodeNameFutures) {
    f.wait();
}


Писать подобный код с использованием фукнций обратного вызова — не очень приятное дело. Использование механизма std::future сильно упрощает подобные задачи. Например, я использовал этот механизм для реализации функции рекурсивного удаления поддерева.

Другие варианты выполнения



Синхронный варинт выполнения команды мы получаем практически бесплатно:

template <typename CmdType>
ResultOf<CmdType> runSync(Session & session, CmdType const& cmd) {
    return runAsync(session, cmd).get();
}


Можно придумать ещё много различных вариантов выполнения. Например, можно довольно легко написать функцию, которая будет повторно выполнять команду при возникновении ошибок соединения, или записывает параметры, начало и конец выполнения команд (например, используя trace_event API chromium) для отладки и анализа производительности.

По сути мы получили примитивную и сильно ограниченную версию аспектно-ориентированной парадигмы. Мы можем выполнять дополнительные действия до запуска и после завершения команд, локализовав логику в рамках одной функции — «аспекта».

Заключение



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

Описанный подход, разумеется, не является чем-то принципиально новым. Как минимум, похожая практика используется в Java-клиенте HBase.

У этого метода есть и недостатки — он порождает довольно много классов, и исследовать интерфейс библиотеки клиентам становится немного сложнее — операции не собраны воедино в интерфейсе класса, а разнесены по разным типам. По этой же причине клиентам будет затруднительно исследовать API По ссылке доступны видео и презентацичерез автодополнение в IDE (впрочем, может оно и к лучшему — хоть документацию почитают). Следовательно, при таком построении интерфейса желательно иметь подробную документацию и побольше примеров использования библиотеки.

UPD: материал, изложенный в статье, послужил основой для доклада «Практичный API для хранилища данных», с которым автор выступал 4 июля на C++ User Group в Нижнем Новгороде. По ссылке доступны видео и презентация в pdf.
Tags:
Hubs:
+10
Comments 2
Comments Comments 2

Articles