🔧

Kotlin + Spring Boot + Cloud Spanner 入門

2022/12/03に公開

Ubie では Kotlin + Spring Boot で作っているサービスが複数あり、そのうちいくつかは DB に Cloud Spanner を使用しています。
このあたりの実装を進めていると全然まとまった情報に出会えなかったので、今回は「これから Spring で Cloud Spanner を使おう」と思っている方向けに、ドキュメントへのリンク多めでまとめていこうと思います。

そもそも Cloud Spanner とは

Cloud Spanner とは Google Cloud が提供するサービスの一つで、端的に言うと 分散型リレーショナルデータベース [1]といった感じでしょうか。
リレーション構造を持ちながらも read, write ともに処理が分散されるので水平スケーラビリティを担保したデータベースです。
なお、最近 PostgreSQL Interface が提供され初めましたが、今回はこれは使わずに実装していきます。
この記事ではこれ以降 Cloud Spanner について詳しくは述べないので、詳しくは公式ドキュメントを参照ください。

Spring + Cloud Spanner

Cloud Spanner 向けのライブラリは様々な言語、フレームワーク向けに提供されていますが、 Java 向けの選択肢は特に多いです。

まず1つ目の選択肢としては、 Spring 傘下で Spring Data プロジェクトの一員である Spring Data Cloud Spanner を使う手が有力です。
GCP 公式ガイドにも比較的詳しく書かれており、 Spring Data JPAHibernate ORM のような機能が Spanner 向けに実装されています。
機能的には Batch DML 以外は全てサポートされており、更新も活発です

これを使った、Spring Boot application with Cloud Spanner という Java で書かれたチュートリアル(Codelab)があり、ページ数も少ないのでこちらをサクッと見てみるのもイメージが湧いて良いと思います。

2つ目の選択肢として Hibernate ORM を Cloud Spanner 向けに使うための Google Cloud Spanner Dialect for Hibernate ORM を使う手があります。
一応 GCP 公式ガイド もありますが、「Hibernate ORM を知ってるなら使い方はわかるよね?」と言わんばかりで、ほぼほぼリンク集としての役割しかありません。
使用には一部の制限があるのと、Mutationが使えませんが、基本的な機能は概ねサポートされています。

こちらも Cloud Spanner with Hibernate ORM という Codelab があるので興味があれば見てみると良さそうです。

これら以外の選択肢も一応ありますが、現実的には上記のどちらかを選ぶことになりそうです。
簡単に紹介だけしておきます。

今回は Spring Data Cloud Spanner を使った実装 をしていきます。

実際に使ってみる

0. Set Up

Cloud Spanner インスタンスと DB を作っておきましょう[2]
今回は実際の Spanner ではなく、公式から提供されている Emulator を使用します。

https://cloud.google.com/spanner/docs/emulator

$ gcloud emulators spanner start
$ gcloud config configurations create emulator
$ gcloud config set auth/disable_credentials true
$ gcloud config set project sample-prj
$ gcloud config set api_endpoint_overrides/spanner http://localhost:9020/
  
$ gcloud spanner instances create test-instance \
   --config=emulator-config --description="Test Instance" --nodes=1
$ gcloud spanner databases create test-db --instance test-instance

準備はできたので、DB とテーブルを作っておきます。

$ gcloud spanner databases ddl update test-db --instance test-instance --ddl='
CREATE TABLE orders (
  order_id STRING(36) NOT NULL,
  description STRING(255),
  creation_timestamp TIMESTAMP,
) PRIMARY KEY (order_id)'

$ gcloud spanner databases ddl update test-db --instance test-instance --ddl='
CREATE TABLE order_items (
  order_id STRING(36) NOT NULL,
  order_item_id STRING(36) NOT NULL,
  description STRING(255),
  quantity INT64,
) PRIMARY KEY (order_id, order_item_id),
  INTERLEAVE IN PARENT orders ON DELETE CASCADE'

軽くデータを入れてテストしておきましょう

$ gcloud spanner rows insert --instance=test-instance --database test-db --table=orders --data=order_id=1c181c2e-b37c-47a2-8cd6-9b418032a391,description="First Test"
$ gcloud spanner databases execute-sql --instance test-instance test-db --sql='SELECT * FROM orders'
# order_id                              description  creation_timestamp
# 1c181c2e-b37c-47a2-8cd6-9b418032a391  First Test

この辺の手順は spanner-cli という CLI ツールを使うとインタラクティブにテーブルをいじれたりするのでこちらもおすすめです。

$ $(gcloud emulators spanner env-init)
$ spanner-cli -p your-project-id -i test-instance -d test-db
# Connected.
# spanner> show databases;
# +----------+
# | Database |
# +----------+
# | test-db  |
# +----------+
# 1 rows in set (0.01 sec)

1. Install

Spring Data Cloud Spanner (spring-cloud-gcp-starter-data-spanner) を install していきます[3]

Maven:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-gcp-starter-data-spanner</artifactId>
</dependency>

Gradle:

dependencies {
    implementation("org.springframework.cloud:spring-cloud-gcp-starter-data-spanner")
}

Cloud Spanner への接続情報を application.propertiesapplication.yml に書いておきます。

spring:
  cloud:
    gcp:
      spanner:
        instance-id: test-instance
        database: test-db
        project-id: sample-prj

これで準備は整いましたが、もし Intellij IDEA を利用する場合は実行時に環境変数を読み込まれるように Run Configurations で Environment Variable に追加しておきましょう。

2. Object Mapping

Spanner のデータ構造をマッピングする Object を定義していきましょう。

@Table(name="orders")
class Order (
  @PrimaryKey
  @Column(name="order_id")
  val id String,

  val description String,

  @Column(name="creation_timestamp")
  val timestamp LocalDateTime,

  @Interleaved
  val items List<OrderItem>,
) { }
@Table(name="order_items")
class OrderItem (
  @PrimaryKey(keyOrder = 1)
  @Column(name="order_id")
  val orderId String,

  @PrimaryKey(keyOrder = 2)
  @Column(name="order_item_id")
  val orderItemId String,

  val description String,
  val quantity Long,
) { }

ポイントはこの辺りです。

  • インターリーブを表現するために @Interleaved アノテーションをつけておく
  • OrderItem は PRIMARY KEY (order_id, order_item_id) に合わせて keyOrder を書いておく
  • Spanner の Int64 型は Kotlin の Long 型に対応させる
  • Spanner の TIMESTAMP 型は com.google.cloud.Timestamp も使えるが LocalDateTime でも OK
  • 今回はシンプルに class で書いているが、 data class にも対応しているので用途に合わせて変更する

3. 読み書きの実装

3パターンの実装方法があり、それぞれ特徴があります。

  1. SpannerRepository を使う
  2. SpannerTemplate を使う
  3. DatabaseClient を使う

今回は簡単のために SpannerRepository を使うパターンで実装してみます。

Spring Data Cloud Spanner では SpannerRepository interface が提供されています。
SpannerRepository は Spring Data の PagingAndSortingRepository, CrudRepository を継承しているため、使い方としてはこれらと同様です。
SpannerRepository を継承したinterface を定義して使います。
ポイントとしては

  • CRUD 周りの関数を自動生成をしてくれるので便利
  • カスタムクエリもアノテーションで対応可能
    • 一方、命名規則などを覚える必要がある

といった感じでしょうか。

実際に実装してみましょう。

interface を定義しておき、

import org.springframework.cloud.gcp.data.spanner.repository.SpannerRepository

interface OrderRepository : SpannerRepository<Order, String>

あとは Spring Data と同様に、こんな感じで利用できます。

class OrderController(
    private val orderRepository: OrderRepository,
    private val orderItemRepository: OrderItemRepository,
) {
    @GetMapping("/order")
    fun index(): Order? {
	val order = orderRepository.save(
	    Order(
		id = "11111111-2222-3333-4444-555555555555",
		description = "Test Order",
		timestamp = LocalDateTime.now(),
		items = emptyList(),
	    )
	)
	orderItemRepository.saveAll(
	    arrayListOf(
		OrderItem(
		    orderId = order.id,
		    orderItemId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
		    description = "Test Order Item",
		    quantity = 3
		),
		OrderItem(
		    orderId = order.id,
		    orderItemId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
		    description = "Test Order Item",
		    quantity = 1
		)
	    )
	)
	val ret = orderRepository.findById(order.id)
	return ret.orElseGet(null)
    }

spanner-cli で確認してみるとこんな感じ[4]

spanner> SELECT * FROM orders INNER JOIN order_items USING (order_id);
+--------------------------------------+-------------+-----------------------------+--------------------------------------+-----------------+----------+
| order_id                             | description | creation_timestamp          | order_item_id                        | description     | quantity |
+--------------------------------------+-------------+-----------------------------+--------------------------------------+-----------------+----------+
| 11111111-2222-3333-4444-555555555555 | Test Order  | 2022-12-03T07:24:50.103974Z | AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE | Test Order Item | 1        |
| 11111111-2222-3333-4444-555555555555 | Test Order  | 2022-12-03T07:24:50.103974Z | aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee | Test Order Item | 3        |
+--------------------------------------+-------------+-----------------------------+--------------------------------------+-----------------+----------+
2 rows in set (832.667us)

おわりに

以上、簡単に Spanner を Spring Boot から実際に使うまでを紹介しました。
なかなか日本語ドキュメントが無い & 公式ドキュメントが 🤪😇🤬🥱 って感じなので調べ甲斐(?)がありました。
参考になれば幸いです。

ref

脚注
  1. 公式には A no-compromise relational database service と強気に書かれています ↩︎

  2. この DB 管理自体も Kotlin のツールを使っても良かったんですが、一旦 Web App としての振る舞いとして必要な記述に集中することにしました ↩︎

  3. ちなみに spring-cloud-gcp-data-spanner という artifact もありますが、starter の方だと GCP 周りで必要な依存をすべて入れてくれるので特別な理由がなければ starter で良いです ↩︎

  4. パフォーマンスを考える必要があるケースでは、 JOIN を使わずに SELECT AS STRUCT を使ったサブクエリを利用するほうが良いです ↩︎

Ubie テックブログ

Discussion