Операции как объекты
Не так давно мне пришлось обращаться к хранилищу 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
ParentType & setVersion (Version version) {
this→version_ = version;
return static_cast
Version version () const { return this→version_; }
private: Version version_; }; Для того, чтобы setVersion возвращал тип базового класса Versionable, здесь используется техника curiously recurring template pattern. Добавление примесей в команды выглядит следующим образом:
struct DeleteCmd: Pathable, Versionable
using VoidCallback =
std: function
using StatCallback =
std: function
using ExistsCallback =
std: function
using StringCallback =
std: function
using ChildrenCallback =
std: function
using DataCallback =
std: function
using AclCallback =
std: function
Самый простой способ привязать команды к коллбэкам — требовать, чтобы каждая команда определяла соответствующий вложенный тип 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
Важно, что перегруженный оператор вызова является константным — команды не должны изменять своё состояние при вызове. Во-первых, это позволит использовать временные объекты команд в коде следующего вида:
session.run (DeleteCmd («path»).setVersion (knownVersion), myCallback); Этого же эффекта можно было добиться, передавая команды по значению и полагаясь на move-семантику, но это привело бы к необходимости в некоторых случаях создавать излишние копии.
Во-вторых, так мы сообщаем человеку, читающему код (и, отчасти, компилятору), что повторное выполнение одной и той же команды не должно приведить к побочным эффектам, не относящимся к изменению структуры хранилища.
Теперь мы можем асинхронно выполнять все операции со всеми возможными опциями, и для этого в клиенте нам нужен только один метод — run.
Добавляем асинхронное выполнение команд c std: future Итак, теперь настала очередь реализовать то, ради чего всё и затевалось — альтернативные варианты выполнения.
Для возможности асинхронного выполнения команд с объектом std: future хочется иметь функцию, обладающую следующей сигнатурой:
template
Для начала нужно понять, как уместить параметры коллбэка команды в одно значение. Именно для этого и нужна метафункция ResultOf. Есть несколько способов указать соответствие параметров функции возвращаемым значениям, я выбрал самый простой — просто выписать каждый возможный случай в виде отдельной специализации шаблонного класса DeduceResult.
template
template <>
struct DeduceResult
template
template
template
Если в коллбэк передаётся только код ошибки, то результат операции — 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
template
template
PromisePtr promisePtr;
CallbackBase () : promisePtr (std: make_shared
std: promise
template
template <>
struct FutureCallback
template >typename T>
struct FutureCallback
std: vector
Другие варианты выполнения Синхронный варинт выполнения команды мы получаем практически бесплатно:
template
По сути мы получили примитивную и сильно ограниченную версию аспектно-ориентированной парадигмы. Мы можем выполнять дополнительные действия до запуска и после завершения команд, локализовав логику в рамках одной функции — «аспекта».
Заключение Превращение операций над хранилищем из методов в объекты уменьшило связность кода и существенно сократило его количество, сэкономив много усилий в реализации и отладке.
Описанный подход, разумеется, не является чем-то принципиально новым. Как минимум, похожая практика используется в Java-клиенте HBase.
У этого метода есть и недостатки — он порождает довольно много классов, и исследовать интерфейс библиотеки клиентам становится немного сложнее — операции не собраны воедино в интерфейсе класса, а разнесены по разным типам. По этой же причине клиентам будет затруднительно исследовать API через автодополнение в IDE (впрочем, может оно и к лучшему — хоть документацию почитают). Следовательно, при таком построении интерфейса желательно иметь подробную документацию и побольше примеров использования библиотеки.