S3 x MongoDB x Kotlin でマスタデータを直接 DB から参照しないシステムを作ろうとした話

こんにちは。cocone connectでサーバー開発を担当しております N です。

今回は、運用時にDBから直接マスタデータを参照しないシステムを作ろうとした話をしたいと思います。

例えば、サービスで扱うアイテムなどのデータを取得する際、DBに直接参照しに行くのではなく、下記のような形にして参照したいと考えたことがありました。

  1. あらかじめデータをJSONファイル化してS3上に置いておく
  2. サーバーの起動時、S3からJSONファイルをサーバーローカルにDLする
  3. アプリからデータを参照する際は、サーバーローカルのJSONファイルを読み込む

これには2つのメリットがあります。

1つは、DBが1つだけでもテスト環境や本番環境といった複数の環境を作成できることです。

もう1つは、環境が異なっても同じデータを見ることができ、かつDBを直接参照しないことです。

新しいデータを登録してもアプリ側には即時反映されないため、「テスト環境で確認を取ってから本番環境に反映する」といった運用が可能になります。

今回は「1. あらかじめデータをJSONファイル化してS3上に置いておく」「3. アプリからデータを参照する際は、ローカルのJSONファイルから読み込む」についてお話しします。

サーバーローカルへJSONファイルをDLする点については割愛いたします(EC2を利用する場合はCodeDeployなどを利用して解決できるかと思います)。

今回の環境

サーバー Kotlin 1.7.0, Springboot 2.6.4
Lambda Python3.9
データベース MongoDB(v5.0.7)

データのJSON化・S3への配置

MongoDBで下記のような ID、アイテム名、リリース日時を持つアイテムデータのコレクション items があったとします。

{
  "_id" : ObjectId("62d586d340c348299177f8ed"),
  "nm" : "Onepiece",
  "rlsAt" : ISODate("2022-06-30T15:00:00.000+0000")
}
{
  "_id" : ObjectId("62d586d340c348299177f8ee"),
  "nm" : "Skirt",
  "rlsAt" : ISODate("2022-06-30T15:00:00.000+0000")
}

 

Lambda から MongoDBへ接続し、全アイテムを取得し、JSONに成形します。

今回は Python3.9およびpymongo を使用します。

pymongo については、ローカルでインストールしたものを関数と一緒に Lambda にアップロードしたり、あらかじめレイヤーを作成しても良いですが、KLayersを利用するのも1つの手かと思います。

Lambda の実行ロールには、アップロード先の S3 バケットへの PutObject など、必要なポリシーをあらかじめアタッチしておきます。

import os
from pymongo import MongoClient
from bson.json_util import dumps
from boto3
 
def lambda_handler(event, context):
  mongo_client = createMongoClient(
    os.getenv('MONGODB_USER'),
    os.getenv('MONGODB_PASSWORD'),
    os.getenv('MONGODB_ENDPOINT')
  )
  # MongoDBからデータ取得
  items = fetchAll(mongo_client, os.getenv('DB_NAME'), 'items')
  collection_json = dumps(items)
 
  # S3にJSONファイルをアップロード
  bucket_name = os.getenv('BUCKET_NAME')
  file_path = os.getenv('FILE_PREFIX') + '/items.json'
  upload_json_file_to_s3(collection_json, bucket_name, file_path)
  
  mongo_client.close()
  return { 'statusCode': 200 }
 
def createMongoClient(user, password, endpoint):
  return MongoClient("mongodb://" + user + ":" + password + "@" + endpoint)
 
def fetchAll(mongo_client, db_name, collection_name):
  db = mongo_client[db_name]
  return db[collection_name].find({})
 
def upload_json_file_to_s3(json_file, bucket_name, file_path):
  s3 = boto3.resource('s3')
  obj = s3.Object(bucket_name, file_path)
  obj.put(
    Body = json_file,
    ContentType="application/json"
  )

itemsをJSONに変換する際、 json.dumps ではなく bson.json_util.dumps を利用します。
(json.dumpsを使用すると失敗するため)
生成されたitems.jsonは以下のようになります。

[
  {
    "_id": {
      "$oid": "62d586d340c348299177f8ed"
    },
    "nm": "Onepiece",
    "rlsAt": {
      "$date": "2022-06-30T15:00:00Z"
    }
  },
  {
    "_id": {
      "$oid": "62d586d340c348299177f8ee"
    },
    "nm": "Skirt",
    "rlsAt": {
      "$date": "2022-06-30T15:00:00Z"
    }
  }
]

これをサーバーローカルにDL済みの想定で、Kotlinのアプリケーションに取り込むことを考えていきます。

アプリケーション側でのJSONファイル読み込み

まずは items に合わせてデータクラス Item を定義します。

import com.google.gson.annotations.SerializedName
import org.bson.types.ObjectId
import java.time.LocalDateTime
 
data class Item(
    @SerializedName("_id")
    val id: ObjectId,
  
    @SerializedName("nm")
    val name: String,
  
    @SerializedName("rlsAt")
    val releaseAt: LocalDateTime? = null
)

SerializedNameアノテーションを使用してJSON(元のコレクション)のキーと一致させます。

次に、JSONファイルを読み込んでItem型のリストにパースする場合は下記になります。

import com.example.ap.datasource.Item
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.io.File
 
class Sample {
    fun jsonToItemList(filePath: String): List<Item> {
        val source = File(filePath).readText(Charsets.UTF_8)
        return convertToDataList(source)
    }
    fun <T> convertToDataList(json: String): List<T> {
        val listType = object : TypeToken<List<T>>() {}.type
        return Gson().fromJson(json, listType)
    }
}

しかし、このままjsonToItemListを実行した場合はreleaseAt(rlsAt)がパースできずにエラーになります。

Method threw 'java.lang.NullPointerException' exception. Cannot evaluate java.time.LocalDateTime.toString()

更に、convertToDataList の戻り値が Item のリストではなく、LinkedTreeMap のリストになってしまうなどの問題もあるため修正します。

修正後のコードを以下に示します。

import com.example.ap.datasource.Item
import com.google.gson.GsonBuilder
import com.google.gson.TypeAdapter
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonWriter
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
 
class Sample {
    fun jsonToItemList(filePath: String): List<Item> {
        val source = File(filePath).readText(Charsets.UTF_8)
        return convertToDataList(source)
    }
 
    // (1) reified type parameter を使用するように修正
    inline fun <reified T> convertToDataList(json: String): List<T> {
        val listType = object : TypeToken<List<T>>() {}.type
        // (2-2) TypeAdapterの適用
        val gson = GsonBuilder()
            .registerTypeAdapter(LocalDateTime::class.java, LocalDateTimeTypeAdapter())
            .create()
        return gson.fromJson(json, listType)
    }
}
 
// (2-1) TypeAdapterの定義
class LocalDateTimeTypeAdapter: TypeAdapter<LocalDateTime>() {
    private val format01 = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
    private val format02 = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
 
    override fun write(out: JsonWriter, value: LocalDateTime) {
        out.beginObject()
            .name("\$date")
            .value(value.format(format01))
            .endObject()
    }
 
    override fun read(`in`: JsonReader): LocalDateTime {
        `in`.beginObject()
        assert("\$date" == `in`.nextName())
        val date = `in`.nextString()
        `in`.endObject()
        return try {
            LocalDateTime.parse(date, format01)
        } catch (de: DateTimeParseException) {
            LocalDateTime.parse(date, format02)
        }
    }
}

(1) JVMにおけるジェネリクスは Type erasure によってコンパイル時に型が削除されてしまい、実行時には型情報が取得できなくなってしまいます。そこで関数を inline 化して型パラメータを reified とすることで、実行時に型を参照することができます。

参考: Reified Functions in Kotlin

(2) (2-1)で定義した LocalDateTime 型に適応した TypeAdapter を実装し、(2-2) で GsonBuilder でその TypeAdapter を割り当てています。

DateTimeFormatterを 2 種用意していますが、ミリ秒以下の値が 0 のときに python で dump した場合に切り捨てられるケースがあるので念の為(今回出力したJSONファイルもそうなってしまっているため……)

修正後の jsonToItemList を実行して出力した結果は以下です。正しく意図通りにデシリアライズされていますし、Item 型も保持されています。

[Item(id=62dd65d41fec4b7e50ceaeec, name=Onepiece, releaseAt=2022-06-30T15:00), Item(id=62dd65d41fec4b7e50ceaeed, name=Skirt, releaseAt=2022-06-30T15:00)]

実際に運用していく場合は、jsonToItemList関数を呼び出してキャッシュに乗せたり、filter()メソッドで必要なデータのみを取り出すなどすればOKです。

最後に

今回は運用時にDBから直接マスタデータを参照しないシステムを作ろうとした話をさせていただきました。

Kotlin での ObjectId と Date(LocalDateTime) のデシリアライズ周りで特に躓いたため重点的に記述しています。

今回は Kotlin でデシリアライズのみを行うため、ObjectId に対応した TypeAdapter は実装していません。シリアライズも必要な場合、そのまま Gson().toJson() を使ってしまうと HexString ではなく、 4 バイトのタイムスタンプや3バイトのマシンID……といった、ここでは不要な情報を含められてしまうことがあります。そのため、 ObjectId に対応した TypeAdapter の実装が必要になります。

担当しているプロジェクトでは方向性が合わず、このシステムはボツになりましたが、運用や方向性次第では最初に述べたメリットが活かせると思います。

 


 

ココネでは一緒に働く仲間を募集中です。

ご興味のある方は、ぜひこちらの採用特設サイトをご覧ください。

https://www.cocone.co.jp/recruit/contents/

Category

Tag

%d人のブロガーが「いいね」をつけました。