<-- mermaid -->

Cloud Bigtableを触ってみよう

こんにちは。
Uzabase SaaS事業の開発チームに所属している掛川です。

私の所属しているチームではユーザーからの入力情報を履歴として保存したいという要件があり、大量の入力履歴を保存するためにGoogleのCloud Bigtableを採用しています。 RDBばかり触ってきた私は、初めてBigtableを一から構築、データの登録を行ってみて戸惑うことばかりでした。
本記事は、私と同じように初めてBigtableを使う方や使おうか検討している方に向けて「Bigtableの概要からテーブルの作成、Kotlinを使った簡単なデータの取得/登録APIの作成まで」を纏めました。

Bigtableについて

Bigtableとは...?

  • Google Cloud BigtableはGoogleが開発した列思考のデータストア
  • 行単位でインデックスが作成されるので、低いレイテンシーでの読み書きが実現できる(※後述の行キーの設定によっては高速な読み書きが実現できなくなってしまう可能性がある)

私が所属しているチームではユーザーからのデータ入力を保存するためにBigtableを使用しましたが、他にも以下のような用途が最適と言われています。

  • 時系列データ:株価や為替、気温など
  • マーケティングデータ:ユーザーの購買履歴や閲覧履歴など
  • IoTデータ:電気の使用量や位置情報など

履歴や大量のデータを扱いたい時にBigtableを使うのが良いかと思います。

行キーや列(カラム)、列(カラム)ファミリーについて

Bigtableでは、Key-Valueのマッピングを1つの行として格納していきます。
テーブルは行(行キー)と列(カラム)で構成されており、1つの行に対して複数の列を登録することができます。
また、列(カラム)ファミリーを用いることで列(カラム)たちを同じ意味の単位でグループ化することも可能です。

イメージとしては以下の様になります。
(私はRDBばかり触ってきたので、最初はカラムやカラムファミリーの感覚が理解出来ず戸惑いました)

それぞれの用語について少し詳しく書いていきます。

行キー:行を特定するためのキー情報。唯一のインデックスになりますので必ず一意である必要があります。
行キーはスキーマの中で辞書順に並べられます。行キーには複数の値(識別子)を含めることができ、区切り文字(コロン、ハッシュ記号、カンマなど)を使って行キーを表現します。

列(カラム)ファミリー:関連する列をグループ化した単位。
関連する列(カラム)はグループ化し、読み取りの際に複雑なフィルターを用いる事無く、また、必要なデータのみを抽出しそれ以外は取得しないようにするのが推奨されているらしい。

列(カラム):列を表す値。RDBでいうところのカラム名に値するもの。
値を列修飾子として付けることでデータとして扱うことも可能。同じ列ファミリーに属している列たちは辞書順に並べられます。

セル:値が入る場所。セルには複数のタイムスタンプ付きのデータが保持できる。保持するセル数を明示的に設定できる(ガベージコレクションについて)。

どのようなスキーマ設計にしていくかは、どのようなデータを格納したいのかに左右されますが、一般的には行範囲での読み込みが早いと言われています。 Bigtableは行でデータを引いてくる事でパフォーマンスを発揮するので、RDBの感覚でカラムを指定して引いてくるやり方を使ってしまうとBigtableの良さを活かしきれません。

ではBigtableを使ってテーブルを構築しながら、簡単なアプリケーションを作って読み書きをしていきたいと思います。

エミュレーターのインストールと起動

今回はローカル開発用のエミュレーターを使用します。
(この記事ではエミュレーターについては記載しませんが、気になる方はこちらをご覧ください)
今回はDockerを使用して実行していく方法でやっていきます。

docker run -p 127.0.0.1:8086:8086 --rm -it --name bigtable-emulator google/cloud-sdk gcloud beta emulators bigtable start --host-port=0.0.0.0:8086

テーブルとカラムファミリーの作成

テーブルとカラムファミリーについては、Dockerで起動しているコンテナに入ってcbtのコマンドを使って作成していきます。
cbtはBigtableで基本的な操作をする時に使用するツールです。今回はDockerのイメージに入っているので、特にインストール等は不要です。

 $(gcloud beta emulators bigtable env-init)
    
 # create table
 cbt -project dummy -instance dummy createtable company_stock
 cbt -project dummy -instance dummy ls

 # create column family
 cbt -project dummy -instance dummy createfamily company_stock stock_price
 cbt -project dummy -instance dummy ls company_stock

(補足)
テーブルの作成

cbt -project dummy -instance dummy createtable <table>

カラムファミリーの作成

cbt -project dummy -instance dummy createfamily <table> <column family>

これでテーブルとカラムファミリーは作成出来ました。

BigtableDataClientを用いたデータの登録

それでは企業の株価データを登録、取得するような簡単なアプリケーションを作っていきたいと思います。
今回はKotlinとQuarkusを使って簡単なREST APIを作りました。(最後にリポジトリのURLを記載しますので、もしよければご覧ください)
Bigtableからデータを取得する部分に関しては、Bigtableのクライアントライブラリを使用し読み書きをしていきます。
(今回はBigtableへの読み書きがメインなので、証券取引所の情報が無かったり、小数点を考慮していない部分等はご容赦下さい...)

企業の1日分の株価データを登録するAPIを想定しています。

Controller層の実装

Controller層です。企業IDとリクエストボディを受け取ってDomainに変換しServiceに処理を流していて行きます。

@Path("/v1/companies")
class CompanyResource(private val service: CompanyStockService) {

    @POST
    @Path("/{companyId}/stocks")
    @Consumes(MediaType.APPLICATION_JSON)
    fun store(@RestPath companyId: String, json: StockJson): Response? {
        service.store(
            CompanyId(companyId),
            StockPrice(Price(json.openPrice, json.closingPrice), Currency(json.currency), LocalDate.parse(json.date))
        )
        return Response.status(201).build()
    }
}

data class StockJson(val date: String, val openPrice: Int, val closingPrice: Int, val currency: String)
Service層とDomainの実装

Service層とDomainの実装です。ServiceではBigtableに保存しやすい様にデータを加工してRepositoryに渡しています。
今回は行キーを「企業ID#日付」としました(企業名っぽい企業IDだと思って下さい...)
(Domainですが、今回はサンプルアプリケーションということもありロジックはありません)

@ApplicationScoped
class CompanyStockService(private val repository: CompanyStockRepository) {

    companion object {
        const val COLUMN_FAMILY_STOCK_PRICE = "stock_price"
        const val COLUMN_OPEN_PRICE = "open_price"
        const val COLUMN_CLOSING_PRICE = "closing_price"
        const val COLUMN_CURRENCY = "currency"
    }

    fun store(companyId: CompanyId, stockPrice: StockPrice) {
        val rowKey = "${companyId.value}#${stockPrice.date}"
        val cells = listOf(
            Cell(COLUMN_FAMILY_STOCK_PRICE, COLUMN_OPEN_PRICE, stockPrice.price.openPrice.toString()),
            Cell(COLUMN_FAMILY_STOCK_PRICE, COLUMN_CLOSING_PRICE, stockPrice.price.closingPrice.toString()),
            Cell(COLUMN_FAMILY_STOCK_PRICE, COLUMN_CURRENCY, stockPrice.currency.value)
        )
        repository.insert(Row(rowKey, cells))
    }

}
data class CompanyId(val value: String)
data class StockPrice(val price: Price, val currency: Currency, val date: LocalDate)

data class Price(val openPrice: Int, val closingPrice: Int)
data class Currency(val value: String)
Repository層の実装

Repository層の実装です。Bigtableへの通信を行います。
BigtableDataClientを使用しデータのinsertを行います。

@ApplicationScoped
class CompanyStockRepository {

    companion object {
        const val TABLE_ID = "company_stock"

        private val client = BigtableDataSettings.newBuilderForEmulator("localhost", 8086)
            .setProjectId("dummy")
            .setInstanceId("dummy")
            .build()
            .let { BigtableDataClient.create(it) }
    }

    fun insert(row: Row) {
        val rowMutation = RowMutation.create(TABLE_ID, row.rowKey)
        row.cells.forEach { rowMutation.setCell(it.columnFamily, it.column, it.value) }
        client.mutateRow(rowMutation)
    }

}

data class Row(val rowKey: String, val cells: List<Cell>)
data class Cell(val columnFamily: String, val column: String, val value: String)

実際に2日分のデータをcurlして、株価データが登録できるかを確かめます。

# 2023/08/01の株価データの登録
curl -XPOST localhost:8080/v1/companies/uzabase/stocks -H 'Content-type:application/json' -d '{"date":"2023-08-01","openPrice":100,"closingPrice":110,"currency":"JPY"}'

# 2023/08/02の株価データの登録
curl -XPOST localhost:8080/v1/companies/uzabase/stocks -H 'Content-type:application/json' -d '{"date":"2023-08-02","openPrice":100,"closingPrice":120,"currency":"JPY"}'

それでは、データが登録できたかcbtコマンドを使って確認してみます。

cbt -project dummy -instance dummy read company_stock

----------------------------------------
uzabase#2023-08-01
  stock_price:closing_price                @ 2023/09/03-05:21:27.903000
    "110"
  stock_price:currency                     @ 2023/09/03-05:21:27.903000
    "JPY"
  stock_price:open_price                   @ 2023/09/03-05:21:27.898000
    "100"

----------------------------------------
uzabase#2023-08-02
  stock_price:closing_price                @ 2023/09/03-05:21:36.155000
    "120"
  stock_price:currency                     @ 2023/09/03-05:21:36.155000
    "JPY"
  stock_price:open_price                   @ 2023/09/03-05:21:36.155000
    "100"

2日分のデータが登録されている事を確認出来ました。

BigtableDataClientを用いたデータの取得

次は企業単位で株価データを取得するようなAPIを作っていきます。

Controller層の実装

Controller層です。企業IDを受け取ってDomainに変換してServiceに処理を流し、戻ってきたデータをJsonクラスに変換してレスポンスを返します。

@Path("/v1/companies")
class CompanyResource(private val service: CompanyStockService) {

    @GET
    @Path("/{companyId}/stocks")
    @Produces(MediaType.APPLICATION_JSON)
    fun get(companyId: String): Response? {
        return service.get(CompanyId(companyId))
            .map { StockJson(it.date.toString(), it.price.openPrice, it.price.closingPrice, it.currency.value) }
            .let { StockResultsJson(it) }
            .let { Response.ok(it).build() }
    }
}

data class StockJson(val date: String, val openPrice: Int, val closingPrice: Int, val currency: String)
data class StockResultsJson(val results: List<StockJson>)
Service層とDomainの実装

Service層です。Domainは登録の処理と変わらないので割愛します。
Repositoryから受け取ったデータを変換し、Controllerへ返却します。

@ApplicationScoped
class CompanyStockService(private val repository: CompanyStockRepository) {

    companion object {
        const val COLUMN_FAMILY_STOCK_PRICE = "stock_price"
        const val COLUMN_OPEN_PRICE = "open_price"
        const val COLUMN_CLOSING_PRICE = "closing_price"
        const val COLUMN_CURRENCY = "currency"
    }

    fun get(companyId: CompanyId): List<StockPrice> {
        val rows = repository.read(companyId.value)
        return rows.map { row ->
            StockPrice(
                Price(
                    row.cells.first { it.column == COLUMN_OPEN_PRICE }.value.toInt(),
                    row.cells.first { it.column == COLUMN_CLOSING_PRICE }.value.toInt()
                ),
                Currency(row.cells.first { it.column == COLUMN_CURRENCY }.value),
                LocalDate.parse(row.rowKey.split("#")[1])
            )
        }
    }
}
Repository層の実装

Repository層の実装です。登録処理の時と同様にBigtableDataClientを使用し、データのreadを行います。
今回は企業単位で株価データを取得するAPIなので、行キーのPrefixでデータを取得したいと思います。

@ApplicationScoped
class CompanyStockRepository {

    companion object {
        const val TABLE_ID = "company_stock"

        private val client = BigtableDataSettings.newBuilderForEmulator("localhost", 8086)
            .setProjectId("dummy")
            .setInstanceId("dummy")
            .build()
            .let { BigtableDataClient.create(it) }
    }

    fun read(key: String): List<Row> {
        val query = Query.create(TABLE_ID).prefix(key)
        val rows = client.readRows(query)
        return rows.map { row ->
            Row(
                row.key.toStringUtf8(),
                row.cells.map { cell -> Cell(cell.family, cell.qualifier.toStringUtf8(), cell.value.toStringUtf8()) }
            )
        }
    }
}

data class Row(val rowKey: String, val cells: List<Cell>)
data class Cell(val columnFamily: String, val column: String, val value: String)

では、実際にcurlしてデータが取得できるか確認してみます。

curl localhost:8080/v1/companies/uzabase/stocks | jq .

{
  "results": [
    {
      "date": "2023-08-01",
      "openPrice": 100,
      "closingPrice": 110,
      "currency": "JPY"
    },
    {
      "date": "2023-08-02",
      "openPrice": 100,
      "closingPrice": 120,
      "currency": "JPY"
    }
  ]
}

先程登録したデータが取得出来ました。

終わりに

私もまだまだBigtableを触り始めたばかりで勉強中ですが、今後Bigtableを触ってみる方へ何か少しでも助けになれば幸いです。

サンプルコードのリポジトリ: GitHub - kakegwa/bigtable-rest-sample-api

Page top