MySQL
Docker
spring-boot

Spring Bootで作ったRESTful APIをコンテナにして動かす

More than 3 years have passed since last update.

親記事

クジラに乗って海に出た


はじめに

さて1週間ぶりの投稿です.

前回は「DockerでWebサービスを作る」というところをメインにお話しましたが, 今回はそのサーバサイドであるSpring Bootで作ったRESTful APIについてのお話です.

最初に言っておきますが, Spring Bootについて全体的な知識を得たいのであればこちらの記事が非常にまとまっていてわかりやすいです.

ということで今回の対象者としては,


  • Spring BootでRESTful APIをすぐにでも試してみたい人

  • DBのテーブルでリレーションを組んだ時の実装を知りたい人

  • Spring BootにおけるDBの初期化について知りたい人

  • どうしてサーバサイドとフロントエンドが完全に切り分けられているのか知りたい人

などなど, ↑の記事の補足or載ってない感じのことを書いていこうと思います!


ディレクトリ構成&ソースコード

.

├── Dockerfile
├── build (中略)
│   │
│   ├── dependency-cache
│   ├── libs
│   │   ├── myapp-0.0.1-SNAPSHOT.jar
│   │   └── myapp-0.0.1-SNAPSHOT.jar.original
│   ├── resources
│   │   └── main
│   │   ├── application.properties
│   │   └── db
│   │   ├── 00-database.sql
│   │   ├── data.sql
│   │   └── schema.sql
│   └── tmp(中略)
│  
|
├── build.gradle
├── build.sh
├── gradle
│   └── wrapper
│   ├── gradle-wrapper.jar
│   └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── run.sh
└── src
├── main
│   ├── java
│   │   └── com
│   │   └── example
│   │   └── myapp
│   │   ├── MyApplication.java
│   │   ├── config
│   │   │   └── AppConfig.java
│   │   ├── controller
│   │   │   ├── AreaController.java
│   │   │   ├── CityController.java
│   │   │   ├── FoodController.java
│   │   │   ├── GenreController.java
│   │   │   ├── PrefectureController.java
│   │   │   ├── RegionController.java
│   │   │   └── RestaurantController.java
│   │   └── domain
│   │   ├── model
│   │   │   ├── Area.java
│   │   │   ├── City.java
│   │   │   ├── Food.java
│   │   │   ├── Genre.java
│   │   │   ├── Prefecture.java
│   │   │   ├── Region.java
│   │   │   └── Restaurant.java
│   │   ├── repository
│   │   │   ├── AreaRepository.java
│   │   │   ├── CityRepository.java
│   │   │   ├── FoodRepository.java
│   │   │   ├── GenreRepository.java
│   │   │   ├── PrefectureRepository.java
│   │   │   ├── RegionRepository.java
│   │   │   └── RestaurantRepository.java
│   │   └── service
│   │   ├── AreaService.java
│   │   ├── CityService.java
│   │   ├── FoodService.java
│   │   ├── GenreService.java
│   │   ├── PrefectureService.java
│   │   ├── RegionService.java
│   │   └── RestaurantService.java
│   └── resources
│   ├── application.properties
│   └── db
│   ├── 00-database.sql
│   ├── data.sql
│   └── schema.sql
└── test
└── java
└── com
└── example
└── myapp
└── MyApplicationTests.java

resources, controller, domainあたりを中心に説明していきます.

ソースコードはこちら

https://github.com/gates1de/MyApp/tree/master/MyApp-server


実装


Resources

resourcesは名前の通り, アプリのリソースを格納するディレクトリになります.

画像やcssといった静的ファイルはここに置くのが良いかと思われます.

そしてresourcesには最初からapplication.propertiesが入っています.

ここにDBの設定を書いていきます.


src/main/resources/application.properties

# MySQL

spring.datasource.url=jdbc:mysql://mysql:3306/myapp_db?characterEncoding=UTF-8
spring.datasource.username=myapp
spring.datasource.password=password
spring.datasource.driverClassName=com.mysql.jdbc.Driver
spring.datasource.initialize=true
spring.datasource.schema=classpath:/db/schema.sql
spring.datasource.data=classpath:/db/data.sql
spring.datasource.sqlScriptEncoding=UTF-8

DBに関する設定は基本的にspring.datasourceの中に定義されているようです.

1行目 : spring.datasource.url

MySQLを外部においた場合のURLはjdbc:mysql://<ホストネーム(IPアドレス)>:<ポート>/<DB名>となります. 後半のcharacterEncoding=UTF-8はおまじないとして使ってます.

今回ホストネームがmysqlとなっているのは, Dockerを立ち上げる時のdocker-compose.ymlでリンクさせていたので, この名前(コンテナ名)にしておくだけで自動的に名前解決をおこなってくれるからなのです.

2, 3行目 : spring.datasource.username, spring.datasource.password

言うまでもないですがMySQLのユーザ名とパスワードです.

これを設定しておけば自動的に初期ユーザとして作成してくれるようです.

4行目 : spring.datasource.driverClassName

JDBC(Java Database Connectivity)はJavaからDBを操作するためのAPIですが, DBごとにドライバが必要になってきます. MySQLの場合com.mysql.jdbc.Driver, PostgreSQLの場合org.postgresql.Driver, といった感じです.

一覧がどこかにまとまっていないかな〜と思いましたが, なかったので使いたいDBのdriverClassNameは各自調べてみてください.

5~7行目 : spring.datasource.initialize ,spring.datasource.schema, spring.datasource.data

DBを初期化するかどうかの設定です.

これをtrueにしておくと, アプリのclasspath(つまり, resourcesディレクトリまでのパス)直下からdata.sqlschema.sqlをアプリの起動時に読み込んでくれます.

ただ, resourcesディレクトリ直下にファイルを置いておきたくなかったので, 自分はdbディレクトリの中に置くことにしました. この時に, 自分でdataschemaを読みこませる方法として6, 7行目の設定を利用すれば良いのです. もちろんファイル名もdata.sqlschema.sqlにしなくてもこの設定さえしておけばどんな名前でも大丈夫です.

8行目 : spring.datasource.sqlScriptEncoding

Dockerの公式MySQLコンテナは日本語に対応してないこともあり, DBのUTF-8設定をちゃんとしておかないと文字化けします.

しかし, DBではちゃんと設定してあったのになぜかdata.sqlで日本語のデータをinsertすると文字化けするという現象がおきて少しハマりました.

それを解決するのがこの設定で, これも一応UTF-8に設定しておきましょう!

DBの初期設定はこれでおkでしょう.

ちなみに, schema.sqlはテーブル定義を書いておくファイルで, テーブルのCREATE文などを書きましょう.

data.sqlには初期データの投入のために, INSERT文などを書きましょう.


Controller

リクエストに対する処理とレスポンスをこのcontrollerでおこないます.

一つピックアップして説明しましょう.


src/main/java/com/example/myapp/controller/CityController.java


package com.example.myapp.controller;

import java.lang.Iterable;
import java.util.Map;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import com.example.myapp.domain.model.City;
import com.example.myapp.domain.model.Restaurant;
import com.example.myapp.domain.model.Food;
import com.example.myapp.domain.service.CityService;
import com.example.myapp.domain.service.RestaurantService;
import com.example.myapp.domain.service.FoodService;

@RestController // ・・・ 1
@RequestMapping("/api/v1/cities") // ・・・ 2
public class CityController {

@Autowired
CityService cityService;

@Autowired
RestaurantService restaurantService;

@Autowired
FoodService foodService;

@RequestMapping(method = RequestMethod.GET) // ・・・ 3
public List<City> getCities() {
return cityService.findAll();
}

@RequestMapping(value = "{id}", method = RequestMethod.GET) // ・・・ 4
public City getCity(@PathVariable Integer id) {
return cityService.findOne(id);
}

@RequestMapping(value = "{id}/foods", method = RequestMethod.GET) // ・・・ 5
public List<Food> getFoods(@PathVariable Integer id) {
return foodService.findByCity(id);
}

@RequestMapping(value = "{id}/restaurants", method = RequestMethod.GET) // ・・・ 6
public List<Restaurant> getRestaurants(@PathVariable Integer id) {
return restaurantService.findByCity(id);
}
}


このCitycontrollerというのは, 主に日本の市町村に関するデータを返すAPIとして動作します.

ちょっとこの辺り命名規則もserviceの使い方も合ってるのかまだ不明ですが, とりあえず動作の説明をしてみます.

1の説明

@RestControllerアノテーションをつけると, 戻り値をResponseBodyのコンテンツとして返してくれる. つまるところ, HTMLのbodyとして返してくれるという認識であってそう. (この記事が非常でわかりやすく説明してくれてます. )

確かにドキュメントみてもそんな感じのことが書いてますね.

2の説明

@RequestMappingは見ただけでも分かると思いますが, いわゆるルーティングの指定です.

ここでの意味はhttp://hogehoge/api/v1/citiesにアクセスしたらCityControllerの処理に入るという感じです.

3の説明

ここから本質のところです. 今度の@RequestMappingは上記のルートにGETでアクセスしたら, getCities()に入る, という感じです.

serviceについては後ほど説明しますが, findAll()と書かれているのを見て, どんなレスポンスになるか分かるかと思います. citiesテーブルから全データ取得してJSON形式で返します.

なぜJSONで返されるのかというと, @RestControllerResponseBodyのコンテンツを返すという話をしましたが, 返り値としてList<>を返すと自動的にJSONにして返してくれるようです.

4の説明

ここでの@RequestMappingは, valuemethodが設定されています. valueは↑で述べたのと同様に, ルートのことです. このルーティングは, http://hogehoge/api/v1/cities/:idにアクセスしてなおかつGETリクエストの場合, getCity()に入る, という感じです.

findOneということはご察しの通り, 該当するidの市町村を1件返すという処理ですね.

5と6の説明

そして今度は市町村じゃないデータが紛れ込んでいる処理ですね.

これはcitiesに紐づくfoodsを取得する処理になります(ご当地グルメとか名物とかだと思って頂ければ).

http://hogehoge/api/v1/cities/:id/foodsGETでアクセスすると, 該当する市町村のグルメを全件取得する, といった処理になります.

6も同様に, 該当する市町村の飲食店を全件取得する処理です(よくグルメサイトとかで, 都道府県から地域や市町村選択してから店の一覧が出てくるみたいなのありますよね).

時間がなくてGETだけのAPIになってしまい残念ですが, なんとなく実装できそうな気がしますよね! 是非POSTPUT, DELETEなども使ってやってみてください!


Model

一番ハマったポイントがこの部分のコーディングです.

また一つ例を取り上げてみます.


src/main/java/com/example/myapp/domain/model/City.java

package com.example.myapp.domain.model;

import java.io.Serializable;
import java.util.Date;
import java.util.List;

import javax.persistence.Entity;
import javax.persistence.Table;
import javax.persistence.Id;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Column;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
import javax.persistence.ManyToOne;
import javax.persistence.OneToMany;
import javax.persistence.ManyToMany;
import javax.persistence.CascadeType;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.JoinTable;

import javax.validation.constraints.NotNull;

import com.example.myapp.domain.model.Area;
import com.example.myapp.domain.model.Restaurant;
import com.example.myapp.domain.model.Food;

import com.fasterxml.jackson.annotation.JsonIgnore;

@Entity
@Table (name = "cities")
public class City implements Serializable {

@Id
@NotNull
@GeneratedValue (strategy = GenerationType.IDENTITY)
@Column (name = "id", nullable = false)
private int id;

@NotNull
@Column (name = "name", nullable = false)
private String name;

@NotNull
@Column (name = "code", nullable = false)
private String code;

@NotNull
@Column (name = "area_code", nullable = false)
private String areaCode;

@Temporal (TemporalType.TIMESTAMP)
@Column (name = "created_at")
private Date createdAt;

@Temporal (TemporalType.TIMESTAMP)
@Column (name = "updated_at")
private Date updatedAt;

@ManyToOne (cascade = CascadeType.ALL, fetch = FetchType.EAGER) // ・・・ 1
@JoinColumn (nullable = false, insertable = false, updatable = false, name = "area_id") // ・・・ 2
private Area area;

@OneToMany (cascade = CascadeType.ALL, fetch = FetchType.EAGER, mappedBy = "city") // ・・・ 3
private List<Restaurant> restaurants;

@ManyToMany (mappedBy = "cities")
private List<Food> foods;

public int getId() {
return this.id;
}

public void setName(String name) {
this.name = name;
}

public String getName() {
return this.name;
}

public void setCode(String code) {
this.code = code;
}

public String getCode() {
return this.code;
}

public void setAreaCode(String areaCode) {
this.areaCode = areaCode;
}

public String getAreaCode() {
return this.areaCode;
}

public Date getCreatedAt() {
return this.createdAt;
}

public void setUpdatedAt(Date updatedAt) {
this.updatedAt = updatedAt;
}

public Date getUpdatedAt() {
return this.updatedAt;
}

@JsonIgnore // ・・・ 4
public Area getArea() {
return this.area;
}

@JsonIgnore
public List<Restaurant> getRestaurants() {
return this.restaurants;
}

@JsonIgnore
public List<Food> getFoods() {
return this.foods;
}
}


全部説明するのはこれまたネットの情報としては冗長的なので, 説明しませんがこちらの記事を見ると良いかもしれません(公開時期的にタイムリーですね!笑)

1の説明

@ManyToOneは多対1の関連があるカラムを表すためのアノテーションになります.

ここではAreaというEntity(Class)が対象となっていますが, これは数カ所の市町村をひとまとめにした「地域」を表すEntityのことを指しています.

例えば, 「東銀座」は「銀座・有楽町・新橋・築地・月島」という地域に属している, という感じです.

「東銀座」は他の地域に属することはなく, 1地域にしか属しません. そして他に, 「汐留」も「銀座・有楽町・新橋・築地・月島」という地域に属します.

よって1つの地域は多くの市町村を持ち, 各市町村は1地域にしか属さない, ということで多対1になり@ManyToOneを付与する形となったわけです.

また, cascade = CascadeType.ALLについてですが, cascadeMySQLにもありますが, 親テーブルが更新された時には子テーブルも更新されるという設定になります. ALLを指定したのでPERSIST, MERGE, REMOVE, REFRESH, DETACHが可能になるようです(まだ学習中なので説明できず...こちらをどうぞ).

fetch = FetchType.EAGERに関しては, 親となるデータを取り出した時にこのデータも一緒に取り出すというのが要約になります. ↑の例で言えば, 「銀座・有楽町・新橋・築地・月島」という地域を取得した時に, 一緒に「銀座」, 「汐留」, ...を取り出すということです.

FetchType.LAZYだと, 親となるデータを取り出しても, 子のデータを参照しなければ取り出さない, 遅延参照になります.

2の説明

@JoinColumnはその名の通り, テーブル結合を表すアノテーションです.

name = "area_id"を指定することで, citiesテーブルのarea_idカラムをもとにjoinすることができます.

nullable = false, insertable = false, updatable = falseはそのままの意味ですが, null・データの追加・データの更新は許容しないことを意味します. つまりは, 読み取り専用のカラムとして定義されるはずです(地域や市町村のデータは基本的に書き換えないのでそうしてます).

3の説明

@OneToManyもその名の通り, 1対多の関連を表すアノテーションです.

mappedBy = "city"なんてのがありますが, これはRestaurantEntity(クラス)の対象プロパティを指します.

ソースコードを見てもらえるとわかると思いますが, Restaurantクラスには@ManyToOneがついたフィールドがあり, それがcityという名前になっているはずです.

こんな感じでリレーションを実現していきます.

4の説明

@JsonIgnoreはちょっと特殊なのですが, JacksonというJSONパーサライブラリを導入することで利用可能にあるアノテーションです(例のごとくこちらをどうぞ).

例えば, controllerの説明の時findAll()とかありましたけど, これが呼び出された時, 全ての地域と各地域に紐づく市町村を全て取得するようになっています.

となると, 市町村のデータは親である地域の情報も同時に取得されます(CityクラスはAreaフィールドを持っているかつ@JoinColumnで結合していて, getArea()も用意されているため).

するとまた, 地域に紐付いた市町村データ全て取得する...という風に循環参照してしまうのです.

これを避けるために@JsonIgnoreアノテーションを付与することでgetArea()はJSONに含めないようにすることができます.

getArea()を使いたいときは直接呼び出すのがいいと思います, 多分.

これはちょっとハマったので覚えておいて憂いなしです.


Repository

repositoryはDBから情報を取得・更新などをおこなう役割を担っています.

また1つピックアップして説明します.


src/main/java/com/example/myapp/domain/repository/FoodRepository.java

package com.example.myapp.domain.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.example.myapp.domain.model.Food;
import com.example.myapp.domain.model.City;

public interface FoodRepository extends JpaRepository<Food, Integer> {

@Query (value = "select * from foods f inner join foods_cities fc on f.id = fc.food_id where fc.city_id = :cityId and f.is_specialty = true", nativeQuery = true)
List<Food> findByCity(@Param("cityId") Integer cityId);

}


まず, JpaRepositoryを継承していることがみてとれますが, こうすることでfindAll()save()など基本的なDB操作のためのメソッドが使えるようになります(ドキュメントはこちら).

JPA(Java Persistence API)は, O/RマッパーとしてJavaのアプリケーションにおけるDB操作を楽にしてくれます. Ruby on RailsではActiveRecordが有名ですね.

そして, @Queryvalueを見てみると, 普通のSQLクエリが書いてありますね.

@Query内のSQLクエリはfindByCity(cityId)というメソッドにて発行できる, といった感じです.

ちなみに, nativeQuery = trueにしておかないと, select * from ...*とかが使えません.


Service

最後に, serviceについてです.

役割としては, repositoryを通じてDBに処理要求をし, 結果を返却するという感じです.

以下例を示します.


src/main/java/com/example/myapp/domain/service/FoodService.java

package com.example.myapp.domain.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.myapp.domain.model.Food;
import com.example.myapp.domain.repository.FoodRepository;

@Service
@Transactional
public class FoodService {
@Autowired
FoodRepository foodRepository;

// 料理全件取得
public List<Food> findAll() {
return foodRepository.findAll();
}

// 料理1件取得
public Food findOne(Integer id) {
return foodRepository.findOne(id);
}

// 市区町村のidに紐づく料理を全て取得
public List<Food> findByCity(Integer cityId) {
return foodRepository.findByCity(cityId);
}
}


もう説明もいらないくらいシンプルな例になってしまって申し訳ないですが, 例えば最後のfindByCity(cityId)について説明すると, FoodRepositoryfindByCity(cityID)にあたるSQLクエリを書いたので, それをFoodService内で呼び出して, DBへ処理要求を出します.

あとは処理結果をそのまま返却するだけにしています.

DBから取得した値をゴニョゴニョ加工したい場合にはここでやるのもいいかと思います.

そしてcontrollerでも説明したとおり, controller内部でservice@Autowiredでインジェクション(インスタンスの手動生成が不要)して, APIにアクセスがあった時に適宜DBから該当するデータ取得し, 結果を返却するという役割を担うわけです.

基本的な使い方に関しては以上になります.


Build

最後にbuild方法をさらっと.

自分はGradleでbuildをおこなうことにしましたので, build.gradleにbuildの設定を書きます.


build.gradle

buildscript {

ext {
springBootVersion = '1.3.5.RELEASE'
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
}
}

apply plugin: 'java' // ・・・ 1
apply plugin: 'spring-boot'

jar {
baseName = 'myapp' // ・・・ 2
version = '0.0.1-SNAPSHOT'
}
sourceCompatibility = 1.8
targetCompatibility = 1.8

repositories {
mavenCentral()
}

configurations {
providedRuntime
}

dependencies {
compile('org.springframework.boot:spring-boot-starter-data-jpa')
compile('org.springframework.boot:spring-boot-starter-redis')
compile('org.springframework.boot:spring-boot-devtools')
compile('org.springframework.boot:spring-boot-starter-web')

compile('com.fasterxml.jackson.core:jackson-core:2.7.4')
compile('com.fasterxml.jackson.core:jackson-databind:2.7.4')
compile('com.fasterxml.jackson.core:jackson-annotations:2.7.4')

runtime('mysql:mysql-connector-java')

providedRuntime('org.springframework.boot:spring-boot-starter-tomcat')

testCompile('org.springframework.boot:spring-boot-starter-test')
}


buildのコマンドは基本的に

$ build gradle

ですが, 今回は最初からテストにコケてしまっているので, 以下のコマンドでbuildします.

$ build gradle -x test

また, コンテナを作成したかったら

$ docker build -t コンテナ名 .

を実行してください.

(もちろん$ docker-machine start VM名でVMを起動, eval $(docker-machine env VM名)をやってから)

今回はjavaコマンドで動くjarファイルを作成したかったので, 上記のコードにある

apply plugin: 'java'(1と書かれた部分)

を入れておくことで, ./build/libsの中にjarファイルが作成されるようになります.

上記のコード2と書かれた部分がアプリケーションの名前とバージョンになりますが, これがjarファイルの名前になります. フォーマットは<baseName>-<version>.jarとなります. 今回の例で言うと, myapp-0.0.1-SNAPSHOT.jarが生成されるはずです.


おわりに

本当はCRUDを実装しようとしましたが間に合わず...

今後の様子を見てやってみようかと思います.

とりあえず, Docker + Spring Bootでサーバサイドを構築するのはそんなに難しいことではないので, 開発環境としてでも作ってみるのがいいと思います!

そしてフロントエンドと切り分けた理由としては, 自分がメインでiOSアプリを開発していたのと, 今時のフロントエンドを学びたかったという背景があり, iOSアプリとWebアプリのどちらも開発に取り組めるようにしたかったことにあります.

WebアプリをSpring Bootで全部作ってしまうと, iOS側の実装にAPIを別で作るのも手間だったのでそういうふうにしました.

とにかく, Web APIはモバイルエンジニアでも作成できたほうが開発の幅が広がって楽しくなると思います.

ということで, 不明な点がありましたらコメントお待ちしております!

それではまた〜!


p.s. 意識がアレだったので, 文章になってなさそうなところあったら教えて下さいw