「複雑さを排除したデータレイクテーブル上のデータアクセスAPI」

「複雑さをなくしたデータレイクテーブル上のデータアクセスAPI」

DuckDBとGoを使用して、S3データレイクファイルの上に堅牢なGraphQL APIサービスを構築する

Photo by Joshua Sortino on Unsplash

1. イントロ

データレイクテーブルは、主にSparkやFlinkなどのビッグデータ処理エンジンを使用するデータエンジニアリングチーム、およびTrinoやRedshiftなどの重いSQLクエリエンジンを使用するデータアナリストや科学者によって利用されることが多い。これらの処理エンジンは、大量のデータを効率的に処理するために設計されており、クラウドベースのオブジェクトストレージの取り扱い、ParquetやORCなどのクエリ最適化された形式のファイルの読み書きなど、ビッグデータの取り扱いに必要なチャレンジを効果的に処理することができる。

ただし、ビッグデータ製品(またはそれらの集計ビュー)を内部のマイクロサービスなどのより軽量なクライアントから利用できるようにすることも一般的な要件です。たとえば、Sparkアプリケーションによって生成された、顧客に関するリアルタイムの統計データを格納するデータレイクテーブルがあるとします。このデータは主に内部レポートに使用されるかもしれませんが、組織内の他のサービスにも価値があるかもしれません。このような要件は一般的であるにもかかわらず、非常にシンプルではありません。特に、かなり異なるツールセットが必要になるためです。S3バケットのParquetファイルを低レイテンシのHTTPベースのAPIで利用可能にすることは簡単ではありません(特にファイルが継続的に更新され、アクセス可能にする前にいくつかの変換が必要な場合)。

このようなユースケースを機能させるためには、一般にはクエリを素早く処理できるデータベースが必要です。同様に、S3のデータファイルを処理し変換し、データベースにロードすることができるETLジョブも必要です。最後に、クライアントのクエリを提供するための適切なAPIエンドポイントを作成する必要があります。

実際にイラストが示すように、薄いクライアントがデータレイクファイルを高速にクエリできるようにすることは、通常、パイプラインにさらに多くの動作部品とプロセスを追加する代償となります。高価な顧客向けのデータウェアハウスにデータをコピーして取り込むか、低レイテンシのデータベースに合わせてデータを集約および変換する必要があります。

この記事の目的は、より軽量なインプロセスクエリエンジンを使用して、この要件に対処するための異なるかつ簡単なアプローチを探求し、示すことです。具体的には、DuckDBとArrow Data Fusionなどのインプロセスエンジンを使用して、データレイクファイルとボリュームを処理し、低レイテンシのAPI呼び出しを提供する高速なメモリストアとして機能するサービスを作成する方法を示します。このアプローチを使用することで、必要な機能を単一のクエリサービスに効率的に統合し、データをロードし、集計し、メモリに保存し、効率的かつ高速にAPI呼び出しを提供することができます。

以下では、セクション2でこのサービスの主な要件とビルディングブロックについて概説し、それらが主なチャレンジを解決するのにどのように役立つかを説明します。セクション3では、データのロードとクエリの機能の核心について詳しく説明し、DuckDBとGoを使用してそれを実装する方法を示します(この記事ではDuckDBとGoに焦点を当てますが、以下のリンクされたレポジトリでArrow Data Fusionを使用したこのコンセプトの実装を見つけることもできます)。セクション4では、GraphQL APIの提供レイヤーを追加します。セクション5では、まとめをします。

2. メインビルディングブロック

このアプローチでは、私たちのAPIで利用可能にしたいデータが、サービスのメモリまたはそれを実行するマシンに収まるという基本的な仮定があることに注意しておくべきです。一部のユースケースでは、これは制限のある条件かもしれませんが、私はこれが思われるほど制限的ではないと考えています。たとえば、メモリ使用量が約350MBで、200万レコードと10列からなるインメモリリレーショナルテーブルを使用してこのアプローチを使用したことがあります。実際に提供するデータは、格納または処理するデータよりもはるかに小さいことがしばしば忘れられがちです。いずれにせよ、これは重要な考慮事項です。

上記に説明されている一般的なアーキテクチャの代替として、シンプルで魅力的なオプションとして動作する独立したサービスは、少なくとも以下の要件に対応する必要があります:

  • データレイクやオブジェクトストレージから直接データファイルを快適に読み込み、変換することができること。
  • リレーショナルデータをメモリ内に格納し、低遅延でクエリに応答することができること。
  • 水平スケーラブルであること。
  • データのクエリ、変換、ロードを簡単かつ宣言的に行うことができること – SQLが最も便利な方法となります。

単純に言えば、アプリケーションのインフラストラクチャをデータベースと追加のETLプロセスで拡張する代わりに、ソースからデータを直接読み込み、効率的に保存し、高速なクエリを実行できるサービスを作成したいと考えています。

私の見解では、DuckDB(この記事の焦点である)とArrow Data Fusionの組み合わせは、これらの3つの特徴の組み合わせとして、最も大きな利点の1つとなります。インメモリDBは新しいものではありませんが、DuckDBとArrow Data Fusionのゲームチェンジャーは、拡張性にあります。これにより、異なる形式や場所のデータを直接読み書きする機能を追加するための拡張機能を簡単に使用し、スケールと高速性を実現できます。

したがって、私たちのサービスは、3つの主要なコンポーネントまたはレイヤで構成され、互いをラップまたはカプセル化します:DuckDB接続(データドライバと呼ぶことにします)をカプセル化する低レベルデータコンポーネント、ドライバを使用してクエリを実行しAPIリクエストを処理するDAOコンポーネント、そしてそれを提供するAPIリゾルバ。

言い換えれば、依存関係とその関係に関して、次の構造を持っています:

API-ResolverはDAO構造体をカプセル化し、DataDriver構造体をカプセル化し、DuckDB Connectionをカプセル化します

次のセクションでは、低レベルのレイヤ(DAO構造体とDataDriver)に焦点を当てます。続くセクションでは、トップのAPIレイヤについて議論し、それらをまとめる方法について説明します。

3. DuckDBを使用したデータのロードとクエリ

このセクションでは、DuckDB接続をラップするドライバコンポーネントを作成します。DuckDBの初期化とSQLステートメントおよびクエリの実行インターフェースを提供する役割を担当します。私たちは、素晴らしいgo-duckdbライブラリを使用します。このライブラリは、DuckDBへのデータベース/SQLインターフェースを提供するために、Cライブラリに静的にリンクします。

DuckDBへのsql.DB接続の初期化

前述のように、適切に初期化を行うDuckDBDriverという名前の構造体で、go-duckdbライブラリが提供するsql.DBインターフェースをラップします。AWSの認証情報(S3からデータをロードするため)、およびサービスに必要な拡張機能(parquetファイルの読み込みのための拡張機能、およびhttpfs(S3などのHTTPベースのオブジェクトストレージからの直接読み込みのための拡張機能))を設定するステートメント(bootQueries)を実行するためのコネクタオブジェクトを使用して、DuckDBを初期化します。

上記のコードブロックに示されているように、関数getBootQueries()は、初期化ステートメントのコレクションを文字列として返すだけです(ステートメントはここで見ることができます)。コネクタがステートメントを実行するため、OpenDB()を呼び出すと、必要な拡張機能とシークレットがロードされたDuckDBをsql.DB接続として取得することができます。go-duckdbはDuckDB接続へのdatabase/sqlインターフェースを提供するため、その主なクエリ機能は次のように簡単に実装および公開できます:

前述のように、DuckDBデータドライバ構造体は、単にDuckDBのDB接続をラップするユーティリティクラスとして機能し、すべてのクエリ実行は、ドライバのメソッドを使用するビジネスロジック関数セットを効果的に管理するDAO構造体によって管理されます。DAO構造体は、さらにDuckDBDriver構造体をラップします。

キャッシュされたデータの読み込み

サービスのデータバックエンドの初期化の最終段階では、S3のパーケットファイルからサーブしたいデータをメモリーテーブルにロードします。これを行うためには、ドライバのexecute()関数を使用し、CTASクエリを使用して名前付きテーブルを作成します。SQLで表現できるいかなる種類の変換も、read_parquet()関数を使用して行います。例を挙げると、より明確になります。

ユーザーを記述しているデータで構成されたパーケットテーブルがあるとします。このパーケットテーブルから名前、姓、年齢の3つのフィールドのみを高速なAPIアクセスのために公開するサービスを作成したいとします。また、年齢フィールドは文字列として保存されていますが、整数としてアクセス可能であることも確認したいとします。

これを行うために、DuckDBのドライバが必要な拡張機能で初期化され、必要なAWSの認証情報が設定された後、read_parquet()関数を使用してS3のパーケットファイルから直接データを選択してメモリにロードするSQL文を単純に実行します。

CREATE TABLE Users AS SELECT NAME, LAST_NAME, CAST(AGE as integer)FROM read_parquet('s3://somewhere/data/*')

この文では、read_parquet()関数に指定された場所のパーケットファイルから選択したフィールドで構成される、メモリ内のテーブルであるUsersが作成されます。DuckDBがサポートする複雑なクエリ文や集計を含む、いかなるSQL関数と構文も使用できます。このアプローチを使用してサービスの初期化を行うフルな例を以下に示します。

実際にこのサービスのコア部分は、サービスが作成され、起動しているときに、そのメモリにS3からデータをロードしているということです。

サービスの初期化が完了し、データがロードされた後は、データドライバで表されるメモリーテーブル上で必要ないかなるSQLクエリも直接実行することができます。これにより、APIの応答時間をサブセコンドで提供することができます。

4. GraphQLの提供

キャッシュされたパーケットデータをロードしたメモリーテーブルへの接続ができたので、最後のステップは、データクエリに効率的に応答するために、それを使用してGraphQLエンドポイントを作成することです。これを行うために、gqlgen by 99designsというライブラリを使用します。このタスクをかなり簡単に行うことができます。

gqlgenの詳細な紹介は、残念ながらここでは範囲外です。GraphQLについてより詳しくない読者は、非常に明確に提示されて説明されているドキュメンテーションをざっと読むことをお勧めします。ただし、GraphQLの概念に少し慣れていることで、このセクションを理解し、アイデアをつかむことが十分です。

gqlgenを使用してGraphQLエンドポイントを公開するには、通常、3つの主要なステップが必要です:(1)スキーマの作成、(2)リゾルバコードとスタブの生成、および(3)API関数を実装するリゾルバコードの追加です。

まず、テーブル内のユーザーを説明するスキーマと、ユーザーデータをフェッチするための2つのメイン関数を指定するこのスキーマから始めましょう。

scalar Timetype User {  name: String!  last_name: String!  email: String!  age: Int!}type Query {  users: [User!]!  getUsersByEmail(email: String!): [User!]!}

スキーマを作成した後、プロジェクトディレクトリでgqlgenのコード生成手順を呼び出します:

go run github.com/99designs/gqlgen generate

生成手順を実行すると、実際のUser構造体(データモデルの構造体)、対応するリゾルバの宣言テンプレート、およびリゾルバの実装を含む多くのコードが生成されます。順番に説明していきましょう。

リゾルバ構造体は、resolver.goという名前のファイルに生成され、2つの文が含まれています。プロパティやメンバーのないstruct型の宣言と、それを初期化するためのコンストラクタ(new()メソッド)です。すぐに見るように、リゾルバはAPIのサービス層であり、APIの各メソッドを実装するための関数を持っています。resolver.goファイルの目的は、リゾルバに必要な依存関係をインジェクトしたり、APIのクエリを応答するために必要なものを追加することです。ここで、これ exactly なDAO構造体の目的です。DAO構造体は、DuckDBデータドライバをラップしており、私たちのインメモリテーブルへの接続を保持し、データのAPIリクエストをSQLクエリに「変換」する責任を持っています。したがって、単に初期化されたDAOオブジェクトをリゾルバにインジェクトし、リゾルバがクエリを実行できるようにします。

// resolver.gotype Resolver struct { dao *data.DAO // DAOに対するrefを追加}func NewResolver(dao *data.DAO) *Resolver { return &Resolver{dao: dao} // DAOを初期化}

次に生成されるファイルはschema.resolvers.goであり、gqlgenのgenerate手順を実行するたびに再生成されます。このファイルは、resolverのメソッドの実装です。生成されたschema.resolversファイルには、スキーマで宣言されたAPI関数のメソッドシグネチャが含まれます。この場合、2つのメソッドになります。

// schema.resolvers.gofunc (r *queryResolver) GetUsersByEmail(ctx context.Context, email string) ([]*model.User, error) {}func (s *DAO) GetUsers() ([]*model.User, error) {}

これらの関数を実装するためには、まずDAO構造体に対応するメソッドが必要ですが、まず例を示してから必要なDAOコードを完成させましょう。

// schema.resolvers.gofunc (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) { res, err := r.dao.GetUsers() if err != nil {  log.Printf("error getting users: %v", err)  return nil, err } return res, nil}

ご覧のように、DAOがresolver構造体に注入されているため、resolverを使用して簡単にその関数を呼び出すことができます。この構造により、APIレイヤーのコードは非常にクリーンでシンプルになります。

さて、DAO構造体で必要な関数の実際の実装を書いてみましょう。以下のように、必要なコードは非常にシンプルです。いくつかのヘルパー関数を使用していますが(コンパニオンのgithubリポジトリで確認できます)、GetUsers()関数は単純にメモリ上のDuckDBテーブルでSQLクエリを実行し、ユーザーのリストを作成します(model.User構造体は、gqlgenがスキーマを使用して生成しました)。

//dao.gofunc (s *DAO) GetUsers() ([]*model.User, error) {//QryAllUsers := "select * from users" rows, err := s.driver.Query(QryAllUsers) if err != nil {  return nil, err } defer rows.Close() resultset, err := sqlhelper.ResultSetFromRows(rows) if err != nil {  return nil, err } users := make([]*model.User, 0) for _, row := range resultset {  user := newUserFromRow(row) // ユーザーの構造体を作成  users = append(users, user) } return users, nil}

これで、必要なチェーンする必要のあるすべてのレイヤーを持っています。つまり、データドライバー構造体(db接続をカプセル化する)が初めに初期化され、それをドライバーに注入してデータをパーケットファイルからメモリにロードするDAOが作成されます。最後に、DAO構造体をAPIハンドラーに注入して、APIリクエストの処理時にその関数を呼び出します。

// server.godataDriver := data.NewDuckDBDriver(awsCred)dataStore := data.NewStore(dataDriver) resolver := graph.NewResolver(dataStore)srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: resolver}))http.Handle("/query", srv)

サービスが初期化されると、まずDuckDBのインメモリストアへの接続を取得するドライバーを初期化します。次に、ドライバーをNewStoreメソッドに注入して、データをパーケットファイルからメモリにロードするDAOを作成します。最後に、DAO構造体をAPIハンドラーに注入して、APIのリクエスト時にその関数を呼び出します。

5. 結論

この記事の目的は、薄いクライアント向けのデータレイクテーブルへのHTTP APIアクセスを可能にする別のアプローチを提供することでした。このようなユースケースはますます一般的になり、通常はパイプラインに多くの移動部品、モニタリング、リソースが追加される必要があります。この記事では、よりシンプルな代替案を提案しました。それは多くのユースケースに適用できると信じています。DuckDBのクエリパフォーマンスと拡張機能を使用して、リモートオブジェクトストレージからデータをロードし、インメモリリレーショナルテーブルに保存し、サブセカンドのレイテンシでクエリできるようにする方法を示しました。より一般的には、DuckDBの拡張機能が私たちのサービスにもたらす素晴らしい機能と、埋め込むことの容易さを例示しました。

役に立つと良いですね!

  • コンパニオンのgithubリポジトリ(サンプルコード)はこちらで確認できます
  • RustとArrow Data Fusionを使用した同じ概念の実装例のgithubリポジトリは、こちらで確認できます

** すべての画像は、特に記載がない限り、著者によるものです

We will continue to update VoAGI; if you have any questions or suggestions, please contact us!

Share:

Was this article helpful?

93 out of 132 found this helpful

Discover more