【備忘録】Spring Boot+MongoDB (Docker) でRESTfulなAPIを作成してCRUDする
はじめに
概要
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¶m2=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 |
開発準備
前提
下記ツールがインストール済みであることを前提として開発準備を行います.
- 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
に変更しましょう.(可読性向上のため)
# Web
server:
servlet:
context-path: /api
MongoDB の起動・初期化・データロード
アプリの起動確認の前に,MongoDB の起動と初期化・初期データロードを終わらせてしまいます.今回は Docker (docker-compose) を用いて MongoDB を立ち上げるので,プロジェクトルートで 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:
mongo
と mongo-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 の初期化スクリプトでは sh
と js
ファイルしか実行されないので,json
ファイルは初期化スクリプトとしては認識されません.[3]
後述の通り,01_import_init_data.json
には 01_import_init_data.sh
で mongoimport
する対象のドキュメント (初期データ) を定義しているだけです.
各ファイルの中身は以下のようにします.まず 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 ファイルに複数のドキュメントが配列形式で定義されているケースでもデータロードが可能になります.
#!/bin/bash
mongoimport --db appdb --collection customer --drop \
--file /docker-entrypoint-initdb.d/01_import_init_data.json \
--jsonArray
02_create_user.js
ではアプリケーションで利用するユーザを作成します.ユーザ名とパスワードは appuser
とします.
// 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 コンソールにアクセスします.
admin → system.users → appdb.appuser を選択すると,アプリケーション用のユーザが作成されていることが確認できます.
また,トップページに戻って appdb → customer を選択すると,01_import_init_data.json
で定義した初期データがロードされていることが確認できます.
以上でデータベースの起動・初期化データロード・起動確認作業が完了となります.続いて,アプリ→データベースへの接続設定をしていきます.
接続設定
src/main/resources/application.yml
に spring.data.mongodb
の設定を追記します.接続先のホスト,ポート,ユーザ名,パスワード,認証データベース,接続先データベースを以下のように設定します.
# MongoDB
spring:
data:
mongodb:
host: localhost
port: 27017
username: appuser
password: appuser
authentication-database: appdb
database: appdb
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
クラスを作成します.
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...
}
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...
}
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
インタフェースを作成します.
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 の型を書きます.
findByCustomerId
と deleteByCustomerId
は JPA 等に馴染みのある方ならすぐわかると思いますが,メソッド名でクエリをかけています.ここでは,第一引数である customerId
が一致する customer ドキュメントを MongoDB から取得/削除しています.findByGenderAndPostCode
メソッドも同様で,引数 gender
と postCode
の両方が一致するドキュメントを検索しています.
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
では全フィールドのチェックはせず,一部フィールドのみテストします.
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
インタフェースを作成します.
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
を作成します.
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
クラスを作成します.
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
のみテストを書いていきます.
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
することで,分岐が正常に行われたか判定しています.具体的には,例えばリクエストに gender
と postCode
が含まれている場合,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 メソッドでアクセスすると,customerId
が USR00001
であるドキュメントが取得できます.
続いて,以下の 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 として raw → JSON を選択し,以下の 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する と重複している部分は端折ったりしたので,もしかしたら少し説明の飛躍を感じる方もいたかもしれません.その場合にはお手数ですが前回記事を参照頂ければと思います.(露骨な宣伝)
最後まで読んで頂きありがとうございました.
-
MongoTemplate
を利用する場合,And
やGreaterThan
等の特定のキーワードでクエリを制御するMongoRepository
よりも柔軟にクエリを発行できます.どちらを採用するかは要件や将来性も鑑みて決めることになると思いますが,どちらも採用するとなると結局メンテナンス大変になったり他の開発者が困惑したりすることになりますので,基本的にはどちらかのみ採用した方が良いと思います.例えば,アプリからは簡単なクエリだけ投げれば済むようにMongoDB側のスキーマを設計したり,要件を絞ってあげたりすれば,機能的にはMongoRepository
で十分です.逆に,簡単なクエリだけでなく複雑なクエリにも対応したい or 将来的に複雑なクエリが追加される可能性があるということであれば,MongoTemplate
を利用した方が柔軟なクエリを発行できるので良いです.とはいえ,MongoDB や MarkLogic のようなドキュメント格納型の NoSQL 製品においては,基本的には API First でスキーマを設計してあげてね,というコンセプトが多いと思いますので,オンラインアプリケーションでそこまで複雑過ぎるクエリは発行させない気もします.(個人的見解) ↩︎ -
初期化がどのように行われているかについては 【MongoDB】MongoDBのDockerコンテナ起動時に認証用ユーザーを作成する がとてもわかりやすいです. ↩︎
-
おおよそ全てのテストクラスに
@SpringBootTest
をつけているソースコードをたまに見かけますが,DI したクラスにテストの依存関係が生まれた結果単体テストの概念が壊れてしまったり,単純にビルドで時間がかかったりして CI/CD のボトルネックになったりします.@SpringBootTest
の利用は必要最低限にし,Mockito 等でモックを作れる場合にはモックを利用してあげましょう. ↩︎
Discussion