オブジェクトとしての操作

少し前までは、C ++コードからZooKeeperリポジトリにアクセスする必要がありました。 libzookeeperシステムライブラリにはまともなC ++ラッパーがなかったため、自分で作成する必要がありました。 実装の過程で、javaライブラリの作成者のアプローチを大幅に変更してAPIを構築し、決定の理由と結果を共有したいと思います。 C ++およびZooKeeperのキーワードにもかかわらず、この記事で説明されているアプローチは、リポジトリへのアクセスを整理するのに適しており、C ++以外の言語でもまったく実装可能です。





はじめに





ZooKeeperは、ノードの階層セット内のデータを表すフォールトトレラントな分散データベースです。 ノードは、作成、変更、削除、存在の確認、およびノー​​ドへのアクセス権の管理ができます。 一部の操作では追加のオプションを使用できます。たとえば、コマンドを適用するノードのバージョンを指定できます。 この記事で説明するマルチスレッドZooKeeperクライアントは、2つの追加ストリームを作成します。1つはすべてのI / O操作を実行し、もう1つはユーザーコールバックとモニターを実行します。 モニターは、ノードの状態が変化したときにクライアントによって呼び出される機能です。 たとえば、ノードが存在するかどうかを確認し、ノードが消えたり表示されたりしたときに呼び出される関数を渡すことができます。 記事を理解するために必要な残りの詳細は、必要に応じて提供します。

複数のデータセンターの多くのマシンによるタスクの実行を調整するために、ZooKeeperが必要でした。



C ++ライブラリの作業を開始したので、ノード上の各操作に1つのメソッドを提供する1つの大きなZooKeeper



クラスを持つJavaクライアントAPIにできるだけ近いAPIを作成することにしまし 。 しかし、このアプローチの欠点はすぐに明らかになりました。



各コマンドにいくつかのオプションが必要でした。







操作にN個の可能なオプション(モニターあり/なし、バージョンあり/なしなど)およびMオプションがある場合、N * Mメソッドを記述してサポートする必要があります。 たとえば、Javaクライアントには4つのexists



メソッド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つのメソッドがあり、これは1つの操作のみです! これは受け入れられないことがわかりました。



急ぎのタイプ





明らかなパスの無益さに気付いた後、APIを再構築するというアイデアを思いつきました。コマンドがチーム自体から実行される方法の分離を最大化する必要があります。 各コマンドは、個別のタイプ(パラメーターのコンテナー)の形式で発行する必要があります。



この場合、クライアントでは、コマンドの非同期実行のためのメソッドを1つだけ実装する必要があります。



 void run(Command cmd, Callback callback);
      
      







基本的な非同期実行オプションのみを実装すれば十分です。他のすべてのオプションは、カプセル化を害することなく 、非同期インターフェースをベースとしてクライアントの外部で実装できます。



そのため、操作ごとに個別のクラスがあります。





各クラスには、コマンドの実行に必要なすべてのパラメーターが格納されます。 たとえば、 delete



操作は必要なパスを取り、オプションで適用可能なバージョンのデータを受け入れることができます。 exists



操作にはパスも必要であり、オプションでノードが削除/作成されたときに呼び出される関数を受け入れることができます。



ここでは、いくつかのテンプレートを選択できます-たとえば、すべてのコマンドにはノードへのパスが含まれている必要があり、一部は特定のバージョンに適用できます( delete



setACL



setData



)、一部は追加のコールバックモニターを受け入れるか、セッションコールバックモニターにイベントを発行できます。 これらの「テンプレート」 をmixinの形式実装できます。このテンプレートから、レンガとしてチームを編成します。 合計で、3つの不純物を見つけることができました。



たとえば、 Versionable



不純Versionable



次のとおり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



setVersion



返すために、 setVersion



では不思議な繰り返しテンプレートパターン技術が使用されています。 コマンドに不純物を追加する方法は次のとおりです。



 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



構造には、UNIXシステムのstat



構造と同様に、ツリーノードに関するメタ情報が含まれています。 たとえば、この構造には、最終変更の仮想時間、ノードに格納されているデータのサイズ、子孫の数などが含まれます。





コマンドをコールバックにバインドする最も簡単な方法は、対応するネストされたCallbackType



タイプを定義するために各コマンドを要求することです。 チームはコールバックと非同期に実行されると推測し始めるため、これは完全に美しいわけではありません。これはまさに避けようとしたものです。 それにもかかわらず、この特定の実装オプションを選択したのは、その単純さと、非同期実行オプションが基本であり、残りのオプションがその上のアドオンになるという事実のためです。



次に、コマンドを非同期に実行するコードを記述する必要があります。 最も簡単なオプションは、パラメーターのパッケージ化とコマンドの非ブロッキング開始の責任をコマンド自体のクラスに割り当てることです。 これは、受け入れられている哲学ともわずかに矛盾しますが、コマンドの非同期処理のすべてのロジックを1か所に保持できます。 ZooKeeperの次のバージョンに新しいコマンドが含まれている場合、ライブラリにクラスを1つだけ追加するだけで十分です。変更は非常にローカルで下位互換性があります。



コマンドインターフェイスの統一のために、抽象型Handle



を導入することにしました-ライブラリクライアントからすべての実装の詳細を隠す低レベルの記述子(たとえば、コマンドの実行にlibzookeeper



ライブラリが使用されるという事実)。 C / C ++では、タイプを宣言することでこれを実現できますが、パブリックライブラリヘッダーファイルで定義することはできません。

 class Handle;
      
      





Handle



クラスの実装方法は、それほど重要ではありません。 簡単にするために、これは実際にはlibzookeeper



ライブラリのlibzookeeper



であり、コマンドをユーザーから密かに実装すると、不完全型へのポインターがzhandle_t



へのポインターにzhandle_t



れると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-semanticsに依存することで同じ効果を実現できますが、これにより、場合によっては冗長コピーを作成する必要が生じます。



第二に、これは、同じコマンドを繰り返し実行しても、リポジトリの構造の変更に関連しない副作用につながるべきではないことをコードを読んでいる人(および一部はコンパイラ)に通知する方法です。



これで、可能なすべてのオプションを使用してすべての操作を非同期的に実行できます。これには、クライアントで1つのメソッドrun



のみが必要です。



std::future



でコマンドの非同期実行を追加std::future







だから、今はすべてが何のために始まったのかを実装する番です-代替オプション。



std::future



オブジェクトを使用したコマンドの非同期実行を有効にするには、次のシグネチャを持つ関数が必要です。



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







この関数は、セッションとコマンドを入力として受け入れ、コマンドの非同期実行の結果を表すstd::future



オブジェクトを返します。



最初に、コマンドのコールバックパラメーターを1つの値に合わせる方法を理解する必要があります。 これがResultOf



メタ関数のResultOf



です。 関数パラメーターと戻り値の対応を示す方法はいくつかありますが、最も単純なものを選択しDeduceResult



。テンプレートクラスDeduceResult



個別の特殊化として、考えられるすべてのケースを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



は単純です。



ResultOf



は、テンプレート同義語( エイリアステンプレート 、C ++ 11の優れた機能の1つ)で、コマンドで定義されたDeduceResult



タイプをDeduceResult



ます。



std::decay



メタ関数の使用は注目に値します-いくつかのパラメーターは参照によってコールバックに渡されますが、値によってクライアントに返したいので、 オブジェクトはスタック上に存在できます。オブジェクトへのリンクを別のストリームに転送すると、クライアントがオブジェクトを読み取るまでにオブジェクトは既に破棄されています。



これで、 runAsync



関数の実装を開始できます。 実装はほとんど明白です:必要なタイプのstd::promise



オブジェクトを作成し、そこからstd::future



オブジェクトを取得する( std::promise::get_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



関数FutureCallback



の実装は、多くの点でResultOf



メタ関数に埋め込まれたロジックを反映しています。 予想される操作のタイプに基づいて、入力引数をオブジェクトにパックし、このオブジェクトをpromise::set_value



またはpromise::set_exception



介して共通の( future



オブジェクトの)状態に渡す関数を生成します。



 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_eventchrom APIを使用 )。



実際、原始的で非常に限定されたバージョンのアスペクト指向のパラダイムを手に入れました。 コマンドを開始する前と完了した後に追加のアクションを実行し、1つの機能のフレームワーク内のロジックをローカライズすることができます-「アスペクト」。



おわりに





リポジトリの操作をメソッドからオブジェクトに変換すると、コードの一貫性が低下し、コードの数が大幅に削減されたため、実装とデバッグの労力が大幅に節約されました。



もちろん、説明したアプローチは根本的に新しいものではありません。 少なくとも、 HBase Javaクライアントでは同様の方法が使用されます



このメソッドには欠点もあります-かなりの数のクラスを生成し、クライアントがライブラリインターフェイスを調べるのが少し難しくなります-操作はクラスインターフェイスで一緒に収集されず、異なるタイプに分離されます。 同じ理由で、クライアントがAPIを調査することは困難です。ビデオとプレゼンテーションは、IDEのオートコンプリートを介してリンクを介して利用できます(ただし、少なくともドキュメントを読んでください)。 したがって、このインターフェイスの構築では、詳細なドキュメントとライブラリの使用例が必要です。



UPD:記事で紹介された資料は、レポート「データウェアハウスの実用的なAPI」の基礎となりました。著者は、7月4日にニジニノヴゴロドのC ++ユーザーグループで講演しました。 リンクは、pdf形式のビデオおよびプレゼンテーションで入手できます。



All Articles