【備忘録】Spring Boot+MongoDB (Docker) でRESTfulなAPIを作成してCRUDする

43 min読了の目安(約39100字TECH技術記事

はじめに

概要

Spring Boot+MongoDB (Docker) を用いたRESTfulなWeb APIを作成して,コレクションに対するCRUD処理 (CREATE/READ/UPDATE/DELETE) を実装してみたいと思います.

基本的な開発の流れは前回記事の 【備忘録】Spring Boot+Postgres+MyBatisでRESTfulなAPIを作成してCRUDする と同様です.本記事では Controller や Service クラスにおける自明な単体テストは省略していますので,気になる方がいらっしゃる場合には前回記事をご参照ください.

また,タイトルにある通り今回は Docker で MongoDB を立ち上げます.ユースケースとしてはローカル開発環境で MongoDB に繋ぎたい場合を想定しているので,ボリュームの永続化等は行いません.

ゴール

Webアプリケーション開発者が開発を始めるときに,環境設定+一通りの処理フローの実装+(MongoDB系の) 単体テストを迷わず実装できるようにすることが本記事執筆の目的となります.具体的には,下記ポイントに焦点を置き,体系的に整理します.

  • Spring Bootを用いたRESTfulなWeb APIな作り方
  • MongoRepository を利用したクエリのかけ方
  • MongoDB接続クラスの単体テストの書き方

なお,本記事では MongoRepository インタフェースを継承してクエリを発行します.MongoTemplate を用いたクエリは登場しませんのでご注意ください.[1]

設計

今回採用するアプリケーション・アーキテクチャは以下の通りとなります.

クライアントからのリクエストを Controller クラスで受け付けて,Service で加工し,Repository で MongoDB への CRUD 処理を行います.Repository では,Spring Data MongoDB で提供されている MongoRepository インタフェースを継承し,特定のキーワードを使って MongoDB へのクエリ発行を行います.

MongoDB に格納するドキュメントは以下のクラス図を満たすような構成となります.階層としては Customer クラスがドキュメントの親で,そのプロパティとして Order が,Order のプロパティとして Product がそれぞれ N で紐付きます.ある顧客は何回か注文をして,注文にはいくつかの商品が含まれる,という現実の構成をクラス化しています.

また,本記事で作成する API のエンドポイントは以下の通りとなります.なお,2 つ目の GET ではネストされたオブジェクト Order へのクエリのかけ方も扱います.

メソッド URI 説明
GET /customers/{id} id の顧客を取得
GET /customers?param1=XXX&param2=XXX 条件を満たす顧客リストを取得
POST /customers 新規顧客登録
POST /customers/{id}/orders id の顧客の新規注文登録
PATCH /customers/{id} id の顧客情報変更
DELETE /customers/{id} id の顧客削除

開発環境

開発環境は以下の通りです.

分類 ツール/バージョン
OS macOS Big Sur 11.1
Java (openjdk) 11.0.2
Spring Boot 2.4.1
Docker 20.10.0
docker-compose 1.27.4
MongoDB 4.4.3-bionic
IDE IntelliJ IDEA
StyleGuide Google Java Style
REST Client Postman

ソースコード

この記事のソースコードは GitHub で公開していますので,興味のある方はそちらも確認頂ければと思います.

開発準備

前提

下記ツールがインストール済みであることを前提として開発準備を行います.

  • JDK
  • Docker
  • docker-compose
  • IDE (e.g. IntelliJ)
  • REST Client (e.g. Postman)

Spring Bootの雛形をダウンロードする

Spring Bootの雛形は spring initializr のサイトからダウンロードします.IDEで直接作成しても良いです.

各項目は以下のように設定しました.Dependenciesは ADD DEPENDENCIES... を押して検索をかけてあげると見つけやすいです.設定後はページ下部の GENERATE を押下してZipファイルをダウンロードしてください.

項目
Project Gradle Project
Language Java
Spring Boot 2.4.1
Project Metadata (Group) com.numacci
Project Metadata (Artifact) restapi-mongodb-crud-tutorial
Project Metadata (Name) restapi-mongodb-crud-tutorial
Project Metadata (Package Name) com.numacci.api
Project Metadata (Packaging) War
Project Metadata (Java) 11
Dependencies Spring Web
Dependencies Spring Data MongoDB

IDE (IntelliJ) に雛形をインポートする

前回記事と全く同じです.先程ダウンロードしたプロジェクトの雛形をIDEにインポートします.まずは下記コマンドを実行して,Zipファイルをワークスペースのディレクトリまで移動→解凍してください.筆者の場合,ワークスペースは $HOME/workspace-java ですので,適宜読み替えてください.

$ cd ~/workspace-java
$ mv ~/Downloads/restapi-mongodb-crud-tutorial.zip .
$ unzip restapi-mongodb-crud-tutorial.zip
$ rm -rf restapi-mongodb-crud-tutorial.zip

次に,IntelliJ IDEAを起動し,File→Open...→Finderで ~/workspace-java/restapi-mongodb-crud-tutorial を選択してOpenします.

Openすると自動でGradleのプロセスが走り,IntelliJ IDEAがSpringを実行するための設定をよしなにやってくれます.(ConfigurationのMain Classの設定とか)

以上でIntelliJ IDEAにプロジェクトの雛形を取り込むことができました.

起動設定変更

Web アプリの起動を確認する前に,アプリケーションのコンテキスト・パスを以下のように変更します.必須ではないですが,コンテキスト・パスを設定しておくと便利なことが多いので設定しておきます.[2]

項目 設定値 デフォルト値
コンテキスト・パス /api -

src/main/resources/application.yml に以下を追記すればOKです.なお,はじめは application.properties になっているかもしれないので,拡張子は yml に変更しましょう.(可読性向上のため)

application.yml
# Web
server:
  servlet:
    context-path: /api

MongoDB の起動・初期化・データロード

アプリの起動確認の前に,MongoDB の起動と初期化・初期データロードを終わらせてしまいます.今回は Docker (docker-compose) を用いて MongoDB を立ち上げるので,プロジェクトルートで docker-compose.yml を新規作成します.各プロパティの設定は以下の通りです.

docker-compose.yml
version: '3'

services:
  mongo:
    image: mongo:4
    restart: always
    ports:
      - 27017:27017
    volumes:
      - mongo:/data/db
      - configdb:/data/configdb
      - ./mongo/init:/docker-entrypoint-initdb.d
    environment:
      MONGO_INITDB_ROOT_USERNAME: mongoAdmin
      MONGO_INITDB_ROOT_PASSWORD: mongoAdmin
      MONGO_INITDB_DATABASE: appdb

  mongo-express:
    image: 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: mongoAdmin
      ME_CONFIG_MONGODB_ADMINPASSWORD: mongoAdmin
    depends_on:
      - mongo

volumes:
  mongo:
  configdb:

mongomongo-express をサービスとして登録しています.mongo-express は Web ベースの MongoDB アドミンツールで,PostgreSQL でいうところの pgadmin に相当するものです.

また,プロジェクトルート直下の ./mongo/init ディレクトリはコンテナ側の /docker-entrypoint-initdb.d にマウントされ,MongoDBの初期化処理に利用されます.

詳細の説明は割愛しますが,以下のプロパティだけは後に利用するので覚えておいてください.

プロパティ 設定値
ポート 27017
データベース名 appdb

続いて,プロジェクトルートで mongo/init ディレクトリを作成し,初期化用のファイルを作成します.以下のコマンドをプロジェクトのルートディレクトリで実行します.

$ mkdir -p mongo/init
$ touch mongo/init/01_import_init_data.json
$ touch mongo/init/01_import_init_data.sh
$ touch mongo/init/02_create_user.js

実行後のプロジェクト構成は以下のようになります.

ファイル名が連番なのは MongoDB の初期化スクリプトの実行順序を保証するためです.01 の番号が被っていますが,MongoDB の初期化スクリプトでは shjs ファイルしか実行されないので,json ファイルは初期化スクリプトとしては認識されません.[3]
後述の通り,01_import_init_data.json には 01_import_init_data.shmongoimport する対象のドキュメント (初期データ) を定義しているだけです.

各ファイルの中身は以下のようにします.まず 01_import_init_data.json では設計で述べた構造を満たす初期データを定義します.

01_import_init_data.json
[
  {
    "customerId": "USR00001",
    "customerName": "Store Customer 01",
    "age": 35,
    "gender": "M",
    "postCode": "999-9999",
    "address": "Tokyo, Japan",
    "orders": [
      {
        "orderId": "ORD00001",
        "totalPrice": 440,
        "orderedProducts": [
          {
            "productName": "apple",
            "quantity": 4,
            "price": 50
          },
          {
            "productName": "orange",
            "quantity": 6,
            "price": 40
          }
        ],
        "orderDate": "2020-01-20",
        "status": "In delivery"
      },
      {
        "orderId": "ORD00002",
        "totalPrice": 2000,
        "orderedProducts": [
          {
            "productName": "chair",
            "quantity": 1,
            "price": 2000
          }
        ],
        "orderDate": "2019-11-09",
        "status": "Delivered"
      }
    ]
  },
  {
    "customerId": "USR00002",
    "customerName": "Store Customer 02",
    "age": 42,
    "gender": "F",
    "postCode": "888-8888",
    "address": "Kyoto, Japan",
    "orders": [
      {
        "orderId": "ORD00003",
        "totalPrice": 4100,
        "orderedProducts": [
          {
            "productName": "colour box",
            "quantity": 3,
            "price": 700
          },
          {
            "productName": "cup",
            "quantity": 4,
            "price": 500
          }
        ],
        "orderDate": "2019-12-24",
        "status": "Delivered"
      }
    ]
  },
  {
    "customerId": "USR00003",
    "customerName": "Store Customer 03",
    "age": 20,
    "gender": "F",
    "postCode": "777-7777",
    "address": "Okinawa, Japan",
    "orders": [
    ]
  },
  {
    "customerId": "USR00004",
    "customerName": "Store Customer 04",
    "age": 12,
    "gender": "M",
    "postCode": "999-9999",
    "address": "Tokyo, Japan",
    "orders": [
    ]
  },
  {
    "customerId": "USR00005",
    "customerName": "Store Customer 05",
    "age": 28,
    "gender": "F",
    "postCode": "999-9999",
    "address": "Tokyo, Japan",
    "orders": [
    ]
  }
]

01_import_init_data.sh では mongoimport コマンドを実行し,先程定義した json ファイルを MongoDB に一括ロードします.この shell ファイルは Docker のコンテナ内で実行されるため,ローカルに mongoimport コマンドがインストールされている必要はありません.

また,--jsonArray オプションをつけてあげることで,1 つの json ファイルに複数のドキュメントが配列形式で定義されているケースでもデータロードが可能になります.

01_import_init_data.sh
#!/bin/bash
mongoimport --db appdb --collection customer --drop \
            --file /docker-entrypoint-initdb.d/01_import_init_data.json \
            --jsonArray

02_create_user.js ではアプリケーションで利用するユーザを作成します.ユーザ名とパスワードは appuser とします.

02_create_user.js
// Define an application user.
let user = {
  user: 'appuser',
  pwd: 'appuser',
  roles: [{
    role: 'readWrite',
    db: 'appdb'
  }]
};
// Execute mongodb command to create the above user.
db.createUser(user);

以上で MongoDB の起動とデータベース初期化の準備が整ったので,早速 Docker を起動してみます.以下のコマンドをプロジェクトのルートディレクトリで実行してください.

$ docker-compose up

Creating volume "restapi-mongodb-crud-tutorial_mongo" with default driver
Creating volume "restapi-mongodb-crud-tutorial_configdb" with default driver
Creating restapi-mongodb-crud-tutorial_mongo_1 ... done
Creating restapi-mongodb-crud-tutorial_mongo-express_1 ... done

# ...(中略)

mongo-express_1  | Welcome to mongo-express
mongo-express_1  | ------------------------
mongo-express_1  | 
mongo-express_1  | 
mongo-express_1  | Mongo Express server listening at http://0.0.0.0:8081

# ...(後略)

最後に,初期化用のデータがデータベースに挿入されているか確認します.http://localhost:8081/ にアクセスし,mongo-express コンソールにアクセスします.

adminsystem.usersappdb.appuser を選択すると,アプリケーション用のユーザが作成されていることが確認できます.

また,トップページに戻って appdbcustomer を選択すると,01_import_init_data.json で定義した初期データがロードされていることが確認できます.

以上でデータベースの起動・初期化データロード・起動確認作業が完了となります.続いて,アプリ→データベースへの接続設定をしていきます.

接続設定

src/main/resources/application.ymlspring.data.mongodb の設定を追記します.接続先のホスト,ポート,ユーザ名,パスワード,認証データベース,接続先データベースを以下のように設定します.

application.yml
# MongoDB
spring:
  data:
    mongodb:
      host: localhost
      port: 27017
      username: appuser
      password: appuser
      authentication-database: appdb
      database: appdb

application.yml の編集はここまでとなります.最終形は以下のようになります.

application.yml
# Web
server:
  servlet:
    context-path: /api
# MongoDB
spring:
  data:
    mongodb:
      host: localhost
      port: 27017
      username: appuser
      password: appuser
      authentication-database: appdb
      database: appdb

以上で開発準備ができましたので,次章からは実際に MongoDB に接続してCRUDをするアプリケーションを作っていきたいと思います.

実装&テスト

DTO クラスの作成

CRUD 機能を追加する前に,MongoDB のドキュメントに対応する Java のクラスを作成していきます.設計の章でクラス図を説明したものです.src/main/java/com.numacci.api.model 配下に Customer クラス,Order クラス,Product クラスを作成します.

Customer.java
package com.numacci.api.model;

import java.util.List;
import org.springframework.data.annotation.Id;

public class Customer {
  @Id
  private String id;
  private String customerId;
  private String customerName;
  private int age;
  private String gender;
  private String postCode;
  private String address;
  private List<Order> orders;

  // Setters and Getters...
}
Order.java
package com.numacci.api.model;

import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDate;
import java.util.List;

public class Order {
  private String orderId;
  private int totalPrice;
  private List<Product> orderedProducts;
  @JsonFormat(pattern = "yyyy-MM-dd")
  private LocalDate orderDate;
  private String status;

  // Setters and Getters...
}

Product.java
package com.numacci.api.model;

public class Product {
  private String productName;
  private int quantity;
  private int price;

  // Setters and Getters...

ただの POJO なので説明は省略します.

Repository の実装

src/main/java/com.numacci.api.repository パッケージ配下に,MongoRepository インタフェースを継承した CustomerRepository インタフェースを作成します.

CustomerRepository.java
package com.numacci.api.repository;

import com.numacci.api.model.Customer;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;

public interface CustomerRepository extends MongoRepository<Customer, String> {

  /**
   * Retrieve a customer document with the same customerId as provided.
   *
   * @param customerId identity of customer
   * @return customer document which has the same customerId as provided
   */
  Customer findByCustomerId(String customerId);

  /**
   * Retrieve customer documents that match the provided gender and postcode.
   * We can use "And" keyword for method name to describe the "and" condition.
   * Please refer to the following link for more information on naming conventions of spring data mongodb.
   *   https://docs.spring.io/spring-data/mongodb/docs/current/reference/html/#mongodb.repositories.queries
   *
   * @param gender gender of the customer
   * @param postCode postcode of the customer
   * @return customer documents which have the same gender and postcode
   */
  List<Customer> findByGenderAndPostCode(String gender, String postCode);

  /**
   * Retrieve customers who have ordered between two provided dates.
   * Conditions can also be applied to fields inside nested objects by simply connecting field names.
   * We can also use "Between" keyword for method name.
   *
   * @param from the earliest date in range "between"
   * @param to the newest date in range "between"
   * @return customers who have ordered between the date
   */
  List<Customer> findByOrdersOrderDateBetween(LocalDate from, LocalDate to);

  /**
   * Retrieve customers who have the order that totalPrice is greater than the provided value.
   *
   * @param minPrice minimum price of totalPrice
   * @return customers who have the order that totalPrice is greater than minPrice
   */
  @Query("{ 'orders.totalPrice' : { $gt : ?0 } }")
  List<Customer> findByTotalPriceGt(int minPrice);

  /**
   * Delete a customer document with the same customerId as provided.
   *
   * @param customerId identity of customer
   * @return deleted object
   */
  Customer deleteByCustomerId(String customerId);
}

MongoRepository インタフェースの Generics では,クエリ対象のオブジェクト (MongoDB のコレクションに対応した Java クラス) と ドキュメント ID の型を書きます.

findByCustomerIddeleteByCustomerId は JPA 等に馴染みのある方ならすぐわかると思いますが,メソッド名でクエリをかけています.ここでは,第一引数である customerId が一致する customer ドキュメントを MongoDB から取得/削除しています.findByGenderAndPostCode メソッドも同様で,引数 genderpostCode の両方が一致するドキュメントを検索しています.

findByOrdersOrderDateBetween メソッドは少し面白くて,customer コレクション内でネストされた orders オブジェクト内のフィールド orderDate に対して検索をかけています.また,Between キーワードを使うことで,2 つの引数日付である from から to の間に注文した顧客ドキュメントを取得しています.この辺のキーワードについては Spring Data MongoDB - Reference Documentation にまとまっていますのでご参照ください.

また,findByTotalPriceGt のように @Query アノテーションを用いることで,MongoDB のコマンドラインでもよく見かける形式でクエリをかけることが可能です.ここでは,customer コレクションにネストされた orders オブジェクトのフィールド totalPrice が引数 minPrice より大きいドキュメントを検索しています.なお,XXX より大きい/以上を検索したい場合には,@Query を利用しなくても GreaterThan または GreaterThanEqual キーワードを利用すれば検索できます.↑は @Query のような書き方があることを書いておきたかっただけだったりします.

Repository のテスト

さて,上記 Repository に対応するテストを書いていきます.src/test/java/com.numacci.api.repository パッケージ配下に CustomerRepositoryTest クラスを作成します.簡単のため,assertEquals では全フィールドのチェックはせず,一部フィールドのみテストします.

CustomerRepositoryTest.java
package com.numacci.api.repository;

import static org.junit.jupiter.api.Assertions.assertEquals;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.numacci.api.model.Customer;
import java.io.File;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.mongodb.core.BulkOperations.BulkMode;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;

@SpringBootTest
public class CustomerRepositoryTest {

  private static final String COL_NAME = "customer";

  private static final String DATA_PATH = "mongo/init/01_import_init_data.json";

  @Autowired
  private CustomerRepository repository;

  @Autowired
  private MongoTemplate mongoTemplate;

  @Autowired
  private ObjectMapper mapper;

  @BeforeEach
  public void setup() throws Exception {
    // Delete all documents from collection.
    mongoTemplate.bulkOps(BulkMode.UNORDERED, Customer.class, COL_NAME)
        .remove(new Query()).execute();

    // Load test data to MongoDB.
    List<Customer> customers =
        Arrays.asList(mapper.readValue(new File(DATA_PATH), Customer[].class));
    mongoTemplate.bulkOps(BulkMode.UNORDERED, Customer.class, COL_NAME)
        .insert(customers).execute();
  }

  @DisplayName("Check that the customer is retrieved by its identity.")
  @Test
  public void testFindByCustomerId() {
    String customerId = "USR00001";

    Customer actual = repository.findByCustomerId(customerId);
    assertEquals(customerId, actual.getCustomerId());
    assertEquals(35, actual.getAge());
    assertEquals("999-9999", actual.getPostCode());
    assertEquals("ORD00001", actual.getOrders().get(0).getOrderId());
    assertEquals("apple",
        actual.getOrders().get(0).getOrderedProducts().get(0).getProductName());
  }

  @DisplayName("Check that the only customers with the provided gender and postCode are retrieved.")
  @Test
  public void testFindByGenderAndPostCode() {
    String gender = "M";
    String postCode = "999-9999";

    List<Customer> actuals = repository.findByGenderAndPostCode(gender, postCode);
    assertEquals(2, actuals.size());
    assertEquals("USR00001", actuals.get(0).getCustomerId());
    assertEquals("USR00004", actuals.get(1).getCustomerId());
  }

  @DisplayName("Check that the only customers who have ordered recently are retrieved.")
  @Test
  public void testFindByOrdersOrderDateBetween() {
    LocalDate from = LocalDate.of(2020, 1, 1);
    LocalDate to = LocalDate.of(2021, 1, 1);

    List<Customer> actuals = repository.findByOrdersOrderDateBetween(from, to);
    assertEquals(1, actuals.size());
    assertEquals("USR00001", actuals.get(0).getCustomerId());
  }

  @DisplayName("Check that the only customers who have ordered expensive products are retrieved.")
  @Test
  public void testFindByTotalPriceGt() {
    int minPrice = 4000;

    List<Customer> actuals = repository.findByTotalPriceGt(minPrice);
    assertEquals(1, actuals.size());
    assertEquals("USR00002", actuals.get(0).getCustomerId());
  }

  @DisplayName("Check that the customer is deleted by its identity.")
  @Test
  public void testDeleteByCustomerId() {
    String customerId = "USR00004";

    Customer actual = repository.deleteByCustomerId(customerId);
    assertEquals(customerId, actual.getCustomerId());
    assertEquals(12, actual.getAge());
    assertEquals("999-9999", actual.getPostCode());

  }
}

CustomerRepository を Dependency Injection (DI) したいので,@SpringBootTest アノテーションをクラスに付与します.[4] また,全てのテストでデータベースのデータを同じ状態にしておきたいので,各テストの前に setup メソッドを実行し,初期データロードで利用した json ファイルを取得→ ObjectMapper でパース→ MongoTemplate クラスの一括削除&一括挿入用メソッド (bulkOps) を実行してデータベースを初期化しておきます.

テストメソッドの中では特別な処理はしていないです.CustomerRepository のメソッドを実行して返ってきた結果と期待値を assertEquals メソッドで比較しています.この辺の期待値を変えてみるとテストが失敗する様子が見れるので面白いかもしれません.あとは Docker を落とした状態でテストを走らせても怒られるので見てみてください.

Service の実装

実際に CustomerRepository を呼び出して処理を実行する Service を開発していきます.まずは src/main/java/com.numacci.api.service パッケージ配下に CustomerService インタフェースを作成します.

CustomerService.java
package com.numacci.api.service;

import com.numacci.api.model.Customer;
import com.numacci.api.model.Order;
import java.time.LocalDate;
import java.util.List;

public interface CustomerService {

  /**
   * Get a customer with the same customer id as provided.
   *
   * @param customerId identity of the customer
   * @return customer who has the same is as provided
   */
  Customer getCustomerById(String customerId);

  /**
   * Get customers that match the provided gender and postcode.
   *
   * @param gender gender of the customer
   * @param postCode postcode of the customer
   * @return customers who have the same gender and postcode
   */
  List<Customer> getCustomerByGenderAndPostCode(String gender, String postCode);

  /**
   * Get customers who have ordered between two provided dates.
   *
   * @param from the earliest date in range "between"
   * @param to the newest date in range "between"
   * @return customers who have ordered between from and to
   */
  List<Customer> getCustomerRecentlyOrdered(LocalDate from, LocalDate to);

  /**
   * Get customers who ordered expensive products.
   *
   * @param minPrice minimum price of total price of an order
   * @return customers whose total price is greater than minPrice
   */
  List<Customer> getCustomerOrderedHighPrice(int minPrice);

  /**
   * Create a new customer.
   *
   * @param customer new customer
   * @return customer newly created
   */
  Customer createCustomer(Customer customer);

  /**
   * Create a new order.
   *
   * @param id identity of the customer
   * @param order new order
   * @return customer newly ordered
   */
  Customer createOrder(String id, Order order);

  /**
   * Update customer information.
   *
   * @param id identity of the customer
   * @param customer target customer
   * @return updated customer
   */
  Customer updateCustomer(String id, Customer customer);

  /**
   * Delete a customer with the same id as provided.
   *
   * @param id identity of the customer
   * @return deleted customer
   */
  Customer deleteCustomer(String id);
}

getXxx()deleteXxx() は Repository のメソッドに 1:1 で紐付きます.createXxx()update は Repository には存在しないメソッドでしたが,いずれも MongoRepository がデフォルトで提供している save メソッドで実現できます.

save メソッドは upsert 的な役割を持っていて,既存のオブジェクトが存在するなら update,存在しないなら insert の処理を行ってくれます.その判断にはコレクションの ID を利用します.今回のケースだと Customer クラスで @Id アノテーションをつけた id フィールドを指していて,顧客 ID である customerId とは異なります.

src/main/java/com.numacci.api.service.impl パッケージ配下に CustomerService を実装したクラスである CustomerServiceImpl を作成します.

CustomerServiceImpl.java
package com.numacci.api.service.impl;

import com.numacci.api.model.Customer;
import com.numacci.api.model.Order;
import com.numacci.api.repository.CustomerRepository;
import com.numacci.api.service.CustomerService;
import java.time.LocalDate;
import java.util.List;
import org.springframework.stereotype.Service;

@Service
public class CustomerServiceImpl implements CustomerService {

  private CustomerRepository repository;

  public CustomerServiceImpl(CustomerRepository repository) {
    this.repository = repository;
  }

  @Override
  public Customer getCustomerById(String customerId) {
    return repository.findByCustomerId(customerId);
  }

  @Override
  public List<Customer> getCustomerByGenderAndPostCode(String gender, String postCode) {
    return repository.findByGenderAndPostCode(gender, postCode);
  }

  @Override
  public List<Customer> getCustomerRecentlyOrdered(LocalDate from, LocalDate to) {
    return repository.findByOrdersOrderDateBetween(from, to);
  }

  @Override
  public List<Customer> getCustomerOrderedHighPrice(int minPrice) {
    return repository.findByTotalPriceGt(minPrice);
  }

  @Override
  public Customer createCustomer(Customer customer) {
    return repository.save(customer);
  }

  @Override
  public Customer createOrder(String customerId, Order order) {
    Customer customer = repository.findByCustomerId(customerId);
    customer.getOrders().add(order);
    return repository.save(customer);
  }

  @Override
  public Customer updateCustomer(String customerId, Customer customer) {
    Customer newCustomer = repository.findByCustomerId(customerId);

    // Set values if the field of requested customer object is not null or zero.
    if (customer.getCustomerName() != null) newCustomer.setCustomerName(customer.getCustomerName());
    if (customer.getAge() > 0) newCustomer.setAge(customer.getAge());
    if (customer.getPostCode() != null) newCustomer.setPostCode(customer.getPostCode());
    if (customer.getAddress() != null) newCustomer.setAddress(customer.getAddress());

    return repository.save(newCustomer);
  }

  @Override
  public Customer deleteCustomer(String customerId) {
    return repository.deleteByCustomerId(customerId);
  }
}

Controller で DI させるために @Service アノテーションはつけます.また,CustomerRepository を Constructor Injection します.Override した各メソッドはだいたいパススルーな処理しかしていません.本来であればクエリ結果のオブジェクトに対して存在チェック (Null チェックや Optional チェック) をしなければならないですが,今回は簡単のため省略しています.

少し処理が書いてあるメソッドについて,createOrder では,customer ドキュメントにネストされた orders にオブジェクトを追加したいので,与えられた customerId で一度検索して顧客オブジェクトを取得し,その orders リストに新規オブジェクトを追加しています.updateCustomer では,リクエストのフィールドの内,非 Null のフィールドを更新予定のオブジェクトにセットし,Update しています.

Service のテスト

今回は Service クラスの処理にロジックがほとんど入っていないので割愛します.モックを利用した Service の単体テストの書き方については 前回記事 をご参照ください.

Controller の実装

いよいよリクエストの受付口である Controller を実装していきます.src/main/java/com.numacci.api.controller パッケージ配下に CustomerController クラスを作成します.

CustomerController.java
package com.numacci.api.controller;

import com.numacci.api.model.Customer;
import com.numacci.api.model.Order;
import com.numacci.api.service.CustomerService;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/customers")
public class CustomerController {

  private CustomerService customerService;

  public CustomerController(CustomerService customerService) {
    this.customerService = customerService;
  }

  @GetMapping("/{id}")
  public Customer getCustomerById(@PathVariable String id) {
    return customerService.getCustomerById(id);
  }

  @GetMapping
  public List<Customer> getCustomerByConditions(@RequestParam Map<String, String> requestParams) {
    // Search customers by their gender and postCode.
    String gender = requestParams.get("gender");
    String postCode = requestParams.get("postCode");
    if (gender != null && postCode != null) {
      return customerService.getCustomerByGenderAndPostCode(gender, postCode);
    }

    // Search customers recently ordered in this site.
    String fromDate = requestParams.get("from");
    String toDate = requestParams.get("to");
    if (fromDate != null && toDate != null) {
      LocalDate from = LocalDate.parse(fromDate, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
      LocalDate to = LocalDate.parse(toDate, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
      return customerService.getCustomerRecentlyOrdered(from, to);
    }

    // Search customers who ordered expensive products.
    String minPrice = requestParams.get("minPrice");
    if (minPrice != null) {
      return customerService.getCustomerOrderedHighPrice(Integer.parseInt(minPrice));
    }

    return new ArrayList<>();
  }

  @PostMapping
  public Customer createCustomer(@RequestBody Customer customer) {
    return customerService.createCustomer(customer);
  }

  @PostMapping("/{id}/orders")
  public Customer createOrder(@PathVariable String id, @RequestBody Order order) {
    return customerService.createOrder(id, order);
  }

  @PatchMapping("/{id}")
  public Customer updateCustomer(@PathVariable String id, @RequestBody Customer customer) {
    return customerService.updateCustomer(id, customer);
  }

  @DeleteMapping("/{id}")
  public Customer deleteCustomer(@PathVariable String id) {
    return customerService.deleteCustomer(id);
  }
}

@RestController アノテーションを付与し,Spring にこのクラスが Controller であることを教えてあげます.また,クラスレベルで @RequestMapping を定義し,このクラスで定義した全ての URI の先頭に /customers を追加します.CustomerService を Constructor Injection し,各 CRUD 用のメソッドを実装します.getCustomerByConditions 以外は割と読んだ通りで,パス・パラメータとしてもらった id や,Body として連携された Customer オブジェクトを CustomerService に渡しているだけです.

getCustomerByConditions が何をしているかというと,リクエスト・パラメータを連携する GET リクエストの受け口として立っていて,実際に送られてきたパラメータから CustomerService の GET 用メソッドを呼び分けています.リクエスト・パラメータは Map で受け取ることが可能です.

なぜ呼び分けるのかというと,Spring では同一の HTTP メソッド+ URI を持つ複数のメソッドが存在することを許容しません.ので,GET - /customers にリクエスト・パラメータをつける URI を色々なパターンで利用したいのであれば,メソッドとしては 1 つに集約して,内部で呼び分けてあげる必要が出てきます.

この辺,もうちょっとちゃんと考えてスキーマやメソッドを設計すればきれいに書ける気がしますが,MongoRepository の使い方を優先して色々書きたかったので断念しました.

Controller のテスト

src/test/java/com.numacci.api.controller パッケージ配下に CustomerControllerTest クラスを新規作成します.Controller もロジックが少ないので,簡単のため分岐が入っている getCustomerByConditions のみテストを書いていきます.

CustomerControllerTest.java
package com.numacci.api.controller;

import static org.mockito.Mockito.any;
import static org.mockito.Mockito.anyInt;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

import com.numacci.api.service.CustomerService;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

public class CustomerControllerTest {

  @Mock
  private CustomerService customerService;

  @InjectMocks
  private CustomerController controller;

  @BeforeEach
  public void setup() {
    MockitoAnnotations.openMocks(this);
  }

  @DisplayName("Check that controller dispatches request properly.")
  @Test
  public void testGetCustomerByConditions() {
    // Test the case of String gender and postCode.
    testGetCustomerByGenderAndPostCode();
    clearInvocations(customerService);

    // Test the case of LocalDate from and to.
    testGetCustomerRecentlyOrdered();
    clearInvocations(customerService);

    // Test the case of Integer minPrice.
    testGetCustomerOrderedHighPrice();
    clearInvocations(customerService);

    // Test the case of invalid request parameters.
    testInvalidRequestParameters();
  }

  private void testGetCustomerByGenderAndPostCode() {
    Map<String, String> requestParams = new HashMap<>();
    String gender = "M";
    String postCode = "999-9999";
    requestParams.put("gender", gender);
    requestParams.put("postCode", postCode);

    controller.getCustomerByConditions(requestParams);
    verify(customerService, times(1)).getCustomerByGenderAndPostCode(gender, postCode);
    verify(customerService, times(0))
        .getCustomerRecentlyOrdered(any(LocalDate.class), any(LocalDate.class));
    verify(customerService, times(0)).getCustomerOrderedHighPrice(anyInt());
  }

  private void testGetCustomerRecentlyOrdered() {
    Map<String, String> requestParams = new HashMap<>();
    String fromDate = "2020-01-01";
    String toDate = "2021-01-01";
    requestParams.put("from", fromDate);
    requestParams.put("to", toDate);

    LocalDate from = LocalDate.parse(fromDate, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
    LocalDate to = LocalDate.parse(toDate, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
    controller.getCustomerByConditions(requestParams);
    verify(customerService, times(0))
        .getCustomerByGenderAndPostCode(any(String.class), any(String.class));
    verify(customerService, times(1)).getCustomerRecentlyOrdered(from, to);
    verify(customerService, times(0)).getCustomerOrderedHighPrice(anyInt());
  }

  private void testGetCustomerOrderedHighPrice() {
    Map<String, String> requestParams = new HashMap<>();
    String minPriceStr = "4000";
    requestParams.put("minPrice", minPriceStr);

    int minPrice = Integer.parseInt(minPriceStr);
    controller.getCustomerByConditions(requestParams);
    verify(customerService, times(0))
        .getCustomerByGenderAndPostCode(any(String.class), any(String.class));
    verify(customerService, times(0))
        .getCustomerRecentlyOrdered(any(LocalDate.class), any(LocalDate.class));
    verify(customerService, times(1)).getCustomerOrderedHighPrice(minPrice);
  }

  private void testInvalidRequestParameters() {
    Map<String, String> requestParams = new HashMap<>();
    requestParams.put("invalidParam", "invalidParam");

    controller.getCustomerByConditions(requestParams);
    verify(customerService, times(0))
        .getCustomerByGenderAndPostCode(any(String.class), any(String.class));
    verify(customerService, times(0))
        .getCustomerRecentlyOrdered(any(LocalDate.class), any(LocalDate.class));
    verify(customerService, times(0)).getCustomerOrderedHighPrice(anyInt());
  }
}

Controller のテストではモック用ライブラリの Mockito を利用します.Controller クラスで Autowired される CustomerService には @Mock アノテーションを付与し,テスト対象の CustomerController には @InjectMocks アノテーションを付与します.また,各テスト前に setup メソッドでモックの初期化を行っています.

今回 Controller は 3 つの CustomerService メソッドを呼び分け,該当しない場合には中身のない ArrayList を返すので,プライベート・メソッドを 4 つ用意し,それぞれの呼び分けパターンについてはその中でテストすることにします.

テストメソッドでは,各呼び分けのパターンにおいて CustomerService のメソッドが何回呼ばれているのか verify することで,分岐が正常に行われたか判定しています.具体的には,例えばリクエストに genderpostCode が含まれている場合,getCustomerByConditions メソッドの 1 つ目の分岐に入ってそのまま return されるため,他の CustomerService メソッドは実行されません.この性質を利用してテストコードを書いています.返り値でテストすることもできますが,今回の Controller だとモックした値を評価することになり,あまり価値がないのでこのような構成としました.

また,各プライベート・メソッド実行の間で clearInvocations(customerService) が実行されていますが,これは customerService のモックの呼び出し回数をリセットしてくれます.これを行わないと,testGetCustomerByGenderAndPostCode 実行後の testGetCustomerRecentlyOrdered でモックの実行回数が合わない (1 つ前のプライベートメソッドの呼び出し回数が加算されてしまう) ことになるので気をつけましょう.

動作確認

それでは最後に動作確認をしていきます.Spring Boot アプリケーションを起動して,Postman を立ち上げてください.念の為,MongoDB が Docker で立ち上がっているかは確認してください.

まず GET /customers/{id} についてです.http://localhost:8080/api/customers/USR00001 に GET メソッドでアクセスすると,customerIdUSR00001 であるドキュメントが取得できます.

続いて,以下の URL に GET メソッドでアクセスしてみましょう.2 列目に示されている顧客 ID を持つドキュメントが返却されるはずです.

URL 返却される顧客の ID
http://localhost:8080/api/customers?gender=M&postCode=999-9999 USR00001, USR00004
http://localhost:8080/api/customers?from=2020-01-10&to=2021-01-20 USR00001
http://localhost:8080/api/customers?minPrice=4000 USR00002

以下は minPrice の例です.

POST についても同様で,HTTP メソッドを POST に変更し,URL に http://localhost:8080/api/customers を入力します.ここで,Body として rawJSON を選択し,以下の json を入力後,Send してください.

{
    "customerId": "USR00006",
    "customerName": "Store Customer 06",
    "age": 92,
    "postCode": "666-6666",
    "address": "Hokkaido, Japan",
    "orders": [
    ]
}

以下のイメージです.

ここで,mongo-express に戻って appdb.customers コレクションを確認してみると,確かに USR00006 の顧客 ID を持つドキュメントが新規作成されていることがわかります.

同じ感じで http://localhost:8080/api/customers/USR00004/orders に対して以下の Body を POST してみましょう.

{
    "orderId": "ORD00099",
    "totalPrice": 2000,
    "orderedProducts": [
        {
            "productName": "apple",
            "quantity": 40,
            "price": 50
        }
    ],
    "orderDate": "2020-01-13",
    "status": "Waiting for delivery"
}

実行後,mongo-express に戻ると,顧客 ID が USR00004 のドキュメントに上記注文が追加されていることがわかります.

UPDATE も確認します.PATCH にメソッドを変更し,http://localhost:8080/api/customers/USR00004 に以下の Body を送ります.

{
    "age": 99,
    "postCode": "111-1111"
}

以下のように,既存の USR00004 の年齢と郵便番号が変更されていることがわかります.

最後に DELETE です.DELETE メソッドに変更し,http://localhost:8080/api/customers/USR00004 にリクエストを送ります.mongo-express に戻って customers コレクションを確認すると,USR00004 のドキュメントが削除されていることがわかります.

以上で動作確認は終了です.

まとめ

ということで,Spring Boot+MongoDB (Docker) でRESTfulなAPIを作成してCRUDをしてきました.MongoRepository を利用すると,キーワードを利用してメソッド名で READ や DELETE 用のクエリを発行できます.また,@Query アノテーションや MongoTemplate 等を用いて,より柔軟なクエリを発行することも可能です.さらに,MongoRepository はデフォルトで upsert 機能を持つ save メソッドを提供していて,これを利用すれば CREATE や UPDATE を容易に行うことができます.

今回は,前回記事である 【備忘録】Spring Boot+Postgres+MyBatisでRESTfulなAPIを作成してCRUDする と重複している部分は端折ったりしたので,もしかしたら少し説明の飛躍を感じる方もいたかもしれません.その場合にはお手数ですが前回記事を参照頂ければと思います.(露骨な宣伝)

最後まで読んで頂きありがとうございました.

脚注
  1. MongoTemplate を利用する場合,AndGreaterThan 等の特定のキーワードでクエリを制御する MongoRepository よりも柔軟にクエリを発行できます.どちらを採用するかは要件や将来性も鑑みて決めることになると思いますが,どちらも採用するとなると結局メンテナンス大変になったり他の開発者が困惑したりすることになりますので,基本的にはどちらかのみ採用した方が良いと思います.例えば,アプリからは簡単なクエリだけ投げれば済むようにMongoDB側のスキーマを設計したり,要件を絞ってあげたりすれば,機能的には MongoRepository で十分です.逆に,簡単なクエリだけでなく複雑なクエリにも対応したい or 将来的に複雑なクエリが追加される可能性があるということであれば,MongoTemplate を利用した方が柔軟なクエリを発行できるので良いです.とはいえ,MongoDB や MarkLogic のようなドキュメント格納型の NoSQL 製品においては,基本的には API First でスキーマを設計してあげてね,というコンセプトが多いと思いますので,オンラインアプリケーションでそこまで複雑過ぎるクエリは発行させない気もします.(個人的見解) ↩︎

  2. 前回記事の脚注 で簡単に理由を述べているので,興味があれば. ↩︎

  3. 初期化がどのように行われているかについては 【MongoDB】MongoDBのDockerコンテナ起動時に認証用ユーザーを作成する がとてもわかりやすいです. ↩︎

  4. おおよそ全てのテストクラスに @SpringBootTest をつけているソースコードをたまに見かけますが,DI したクラスにテストの依存関係が生まれた結果単体テストの概念が壊れてしまったり,単純にビルドで時間がかかったりして CI/CD のボトルネックになったりします.@SpringBootTest の利用は必要最低限にし,Mockito 等でモックを作れる場合にはモックを利用してあげましょう. ↩︎