👀

Kotlin / Spring Boot / MongoDB / DDDでネストされているプロパティへデータを追加する

2022/09/01に公開

はじめに

お疲れ様です。
@いけふくろうです。

WebSocketを使ったリアルタイム性が求められる機能を作ることになったので、私自身としては初めてMongoDBを選定しました。
開発している機能でタイトルの実装が必要となったのですが、Spring Data MongoDBで用意されているデータアクセスのメソッドではなく、カスタムで実装する必要があったため、そのHowについてまとめてみました。

環境

  • Kotlin 1.6.21
  • Java 17
  • Spring Boot 2.7.3
  • MongoDB

実装したいこと

以下のようなデータ構造であるorderコレクションに対して、

  • orderIduserId及びorderDetailIdに合致するproductsプロパティの配列内にデータを追加したい
[
  {
    "orderId": "ede66c43-9b9d-4222-93ed-5f11c96e08e2",
    "orderDate": "2022-08-30 10:00:00",
    "userId": 1,
    "userName": "poc taro",
    "orderDetails": [
      {
        "orderDetailId": "d55847c2-5daa-6e28-fa4a-c3a434029aef",
        "products": [
          {
            "productId": 1,
            "productName": "みかん",
            "price": 200,
            "quantity": 2
          },
          {
            "productId": 3,
            "productName": "ぶどう",
            "price": 400,
            "quantity": 1
          }
        ]
      },
      {
        "orderDetailId": "989a52b9-d2c0-415a-af6f-e412c0e31f33",
        "products": [
          {
            "productId": 101,
            "productName": "ブロッコリー",
            "price": 350,
            "quantity": 2
          }
        ]
      }
    ]
  },
  {
    "orderId": "9c412fcb-9155-4611-b03e-8b1a1a54b54b",
    "orderDate": "2022-08-30 10:00:00",
    "userId": 2,
    "userName": "poc jiro",
    "orderDetails": [
      {
        "orderDetailId": "5901cdf6-ad68-4009-80f5-04995e2dd16d",
        "products": [
          {
            "productId": 2,
            "productName": "りんご",
            "price": 150,
            "quantity": 1
          }
        ]
      }
    ]
  }
]

環境構築

細かい部分の内容は省略しますが、Dockerで構築しました。

docker-compose.yml
version: '3'

services:
  mongo:
    image: poc-mongodb
    container_name: poc-mongodb
    restart: always
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: password123
      MONGO_INITDB_DATABASE: poc
      TZ: Asia/Tokyo
    ports:
      - 27017:27017
    volumes:
      - ./docker/db/mongo/db:/data/db
    build:
      context: .
      dockerfile: ./POC_Dockerfile_MongoDB

  mongo-express:
    image: mongo-express
    container_name: poc-mongo-express
    restart: always
    ports:
      - 8081:8081
    environment:
      ME_CONFIG_MONGODB_SERVER: mongo
      ME_CONFIG_MONGODB_PORT: 27017
      ME_CONFIG_MONGODB_ENABLE_ADMIN: 'true'
      ME_CONFIG_MONGODB_ADMINUSERNAME: root
      ME_CONFIG_MONGODB_ADMINPASSWORD: password123
    depends_on:
      - mongo
POC_Dockerfile_MongoDB
FROM mongo:4

ARG LOCAL_APP_DIR="."

COPY ${LOCAL_APP_DIR}/docker/db/mongo/init /docker-entrypoint-initdb.d/

Dockerの構築用のタスク定義

Justfileを作成しました。
今までは、Makefileを使ってましたが、Rustで作られているモダンなMakefileのようなものらしいので、cargojustコマンドをインストールしてセットアップしてみました!

Justfile
load:
  docker-compose down --rmi all --volumes --remove-orphans
  rm -rf ./docker/db/mongo/db/
  docker-compose up -d --build

Docker構築

% just load

% docker-compose ps
NAME                COMMAND                  SERVICE             STATUS              PORTS
poc-mongo-express   "tini -- /docker-ent…"   mongo-express       running             0.0.0.0:8081->8081/tcp
poc-mongodb         "docker-entrypoint.s…"   mongo               running             0.0.0.0:27017->27017/tcp
%

コレクションの作成確認
http://0.0.0.0:8081/db/poc/order

Infrastructure層

本記事における主となる部分の実装です。

Entity定義

src/main/kotlin/com/ss/poc/infrastucture/order/ProductEntity.kt
data class ProductEntity(
    val productId: Int,
    val productName: String,
    val price: Int,
    val quantity: Int
) {
    constructor(productDto: ProductDto) : this(
        productDto.productId,
        productDto.productName,
        productDto.price,
        productDto.quantity
    )
}
  • サービス層のクラスからはDTOで渡されるので、コンストラクタを用意しています
src/main/kotlin/com/ss/poc/infrastucture/order/OrderDetailEntity.kt
data class OrderDetailEntity(
    val orderDetailId: String,
    val products: List<ProductEntity>
)
src/main/kotlin/com/ss/poc/infrastucture/order/OrderEntity.kt
@Document("order")
data class OrderEntity(
    val orderId: String,
    val orderDate: String,
    val userId: Int,
    val userName: String,
    val orderDetails: List<OrderDetailEntity>
)
  • MongoDBのコレクションであるorderを紐づけています

Repository

src/main/kotlin/com/ss/poc/domain/order/IOrderCustomRepository.kt
interface IOrderCustomRepository {
    fun fetchCountOrderByOrderIdAndUserId(orderId: String, userId: Int, orderDetailId: String): Long
    fun updateOrderProductByOrderIdAndUserId(
        orderId: String,
        userId: Int,
        orderDetailId: String,
        productDto: ProductDto
    ): Boolean
}
src/main/kotlin/com/ss/poc/infrastucture/order/impl/OrderCustomRepository.kt
import org.springframework.data.mongodb.core.MongoTemplate
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.data.mongodb.core.query.Query
import org.springframework.data.mongodb.core.query.Update

@Repository
class OrderCustomRepository(private val mongoTemplate: MongoTemplate) : IOrderCustomRepository {
    override fun fetchCountOrderByOrderIdAndUserId(orderId: String, userId: Int, orderDetailId: String): Long {
        val query = buildQuery(orderId, userId, orderDetailId)
        return mongoTemplate.count(query, OrderEntity::class.java)
    }

    override fun updateOrderProductByOrderIdAndUserId(
        orderId: String,
        userId: Int,
        orderDetailId: String,
        productDto: ProductDto
    ): Boolean {
        val query = buildQuery(orderId, userId, orderDetailId)
        val update = Update().push("orderDetails.$[element].products", ProductEntity(productDto))
            .filterArray(Criteria.where("element.orderDetailId").`is`(orderDetailId))
        val updateResult = mongoTemplate.updateFirst(query, update, OrderEntity::class.java)
        return updateResult.modifiedCount > 0
    }

    private fun buildQuery(orderId: String, userId: Int, orderDetailId: String): Query {
        val query = Query.query(Criteria.where("orderDetails.orderDetailId").`is`(orderDetailId))
        query.addCriteria(Criteria.where("orderId").`is`(orderId).and("userId").`is`(userId))
        return query
    }
}
  • updateOrderProductByOrderIdAndUserIdが対象のメソッドです
val update = Update().push("orderDetails.$[element].products", ProductEntity(productDto))
            .filterArray(Criteria.where("element.orderDetailId").`is`(orderDetailId))

特に、↑の部分がポイントでした。
<こちら>の公式などを頼りにしました。
配列の動的指定部分は、$[element]とするようです。

アプリケーション・サービス

src/main/kotlin/com/ss/poc/application/order/ProductDto.kt
data class ProductDto(
    val productId: Int,
    val productName: String,
    val price: Int,
    val quantity: Int
) {
    constructor(product: Product) : this(
        product.productId,
        product.productName,
        product.price,
        product.quantity
    )
}
  • ドメインクラスは省略しますが、ドメインからDTOへの詰め替え用クラスです
src/main/kotlin/com/ss/poc/application/order/OrderProductUpdateCommand.kt
data class OrderProductUpdateCommand(
    val orderId: String,
    val userId: Int,
    val orderDetailId: String,
    val productId: Int,
    val productName: String,
    val price: Int,
    val quantity: Int
)
  • パラメータをまとめたコマンドオブジェクトです
src/main/kotlin/com/ss/poc/application/order/impl/OrderService.kt
@Service
class OrderService(private val orderCustomRepository: IOrderCustomRepository) : IOrderService {
    override fun updateOrderProduct(command: OrderProductUpdateCommand) {
        // Productドメイン生成
        val product = Product(command.productId, command.productName, command.price, command.quantity)

        // 注文商品を追加する(追加されたかどうかを検証する必要あり)
        val updateResult = orderCustomRepository.updateOrderProductByOrderIdAndUserId(
            command.orderId,
            command.userId,
            command.orderDetailId,
            ProductDto(product)
        )
        if (!updateResult) {
            throw RuntimeException("追加に失敗しました >>> orderId: ${command.orderId} / userId: ${command.userId} / orderDetailId: ${command.orderDetailId}")
        }
    }
}
  • updateOrderProductByOrderIdAndUserIdを呼び出しています

プレゼンテーション

src/main/kotlin/com/ss/poc/presentation/order/UpdateOrderProductRequest.kt
data class UpdateOrderProductRequest(
    @field: NotNull val orderId: String,
    @field: Positive val userId: Int,
    @field: NotNull val orderDetailId: String,
    @field: Positive val productId: Int,
    @field: NotNull val productName: String,
    @field: PositiveOrZero val price: Int,
    @field: NotNull val quantity: Int
)
  • リクエストクラスです
src/main/kotlin/com/ss/poc/presentation/order/OrderController.kt
@RestController
@RequestMapping(value = ["api/v1/order"], produces = ["application/json"])
class OrderController(private val orderService: IOrderService) {
    @PutMapping("/product")
    fun updateOrderProduct(@RequestBody @Validated updateOrderProductRequest: UpdateOrderProductRequest) {
        orderService.updateOrderProduct(
            OrderProductUpdateCommand(
                updateOrderProductRequest.orderId,
                updateOrderProductRequest.userId,
                updateOrderProductRequest.orderDetailId,
                updateOrderProductRequest.productId,
                updateOrderProductRequest.productName,
                updateOrderProductRequest.price,
                updateOrderProductRequest.quantity
            )
        )
    }
}
  • サービス層のupdateOrderProductを呼び出しています

API実行

order
{
    _id: ObjectId('630f66427b550292745347a6'),
    orderId: 'ede66c43-9b9d-4222-93ed-5f11c96e08e2',
    orderDate: '2022-08-30 10:00:00',
    userId: 1,
    userName: 'poc taro',
    orderDetails: [
        {
            orderDetailId: 'd55847c2-5daa-6e28-fa4a-c3a434029aef',
            products: [
                {
                    productId: 1,
                    productName: 'みかん',
                    price: 200,
                    quantity: 2
                },
                {
                    productId: 3,
                    productName: 'ぶどう',
                    price: 400,
                    quantity: 1
                },
                {
                    productId: 4,
                    productName: 'なし',
                    price: 500,
                    quantity: 2
                }
            ]
        },
        {
            orderDetailId: '989a52b9-d2c0-415a-af6f-e412c0e31f33',
            products: [
                {
                    productId: 101,
                    productName: 'ブロッコリー',
                    price: 350,
                    quantity: 2
                }
            ]
        }
    ]
}

以下のproductオブジェクトが追加された!!

おわりに

クエリを組み立てたカスタムで定義するところを経験できたので、類似のクエリが必要な場合には流用できそうでよかったです!
Kotlin / MongoDB / DDDはまだまだなので、とりあえず実装して詰まって解決してなるほど!という数をこなしていきたいと思います!!

以上です。
本記事が何かの一助になれば幸いです。

Discussion