親記事
クジラに乗って海に出た
はじめに
さて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の設定を書いていきます.
# 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.sql
とschema.sql
をアプリの起動時に読み込んでくれます.
ただ, resources
ディレクトリ直下にファイルを置いておきたくなかったので, 自分はdb
ディレクトリの中に置くことにしました. この時に, 自分でdata
とschema
を読みこませる方法として6, 7行目の設定を利用すれば良いのです. もちろんファイル名もdata.sql
とschema.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
でおこないます.
一つピックアップして説明しましょう.
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で返されるのかというと, @RestController
がResponseBody
のコンテンツを返すという話をしましたが, 返り値としてList<>
を返すと自動的にJSONにして返してくれるようです.
4の説明
ここでの@RequestMapping
は, value
とmethod
が設定されています. value
は↑で述べたのと同様に, ルートのことです. このルーティングは, http://hogehoge/api/v1/cities/:id
にアクセスしてなおかつGET
リクエストの場合, getCity()
に入る, という感じです.
findOne
ということはご察しの通り, 該当するid
の市町村を1件返すという処理ですね.
5と6の説明
そして今度は市町村じゃないデータが紛れ込んでいる処理ですね.
これはcities
に紐づくfoods
を取得する処理になります(ご当地グルメとか名物とかだと思って頂ければ).
http://hogehoge/api/v1/cities/:id/foods
にGET
でアクセスすると, 該当する市町村のグルメを全件取得する, といった処理になります.
6も同様に, 該当する市町村の飲食店を全件取得する処理です(よくグルメサイトとかで, 都道府県から地域や市町村選択してから店の一覧が出てくるみたいなのありますよね).
時間がなくてGET
だけのAPIになってしまい残念ですが, なんとなく実装できそうな気がしますよね! 是非POST
やPUT
, DELETE
なども使ってやってみてください!
Model
一番ハマったポイントがこの部分のコーディングです.
また一つ例を取り上げてみます.
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
についてですが, cascade
はMySQL
にもありますが, 親テーブルが更新された時には子テーブルも更新されるという設定になります. 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"
なんてのがありますが, これはRestaurant
Entity(クラス)の対象プロパティを指します.
ソースコードを見てもらえるとわかると思いますが, Restaurant
クラスには@ManyToOne
がついたフィールドがあり, それがcity
という名前になっているはずです.
こんな感じでリレーションを実現していきます.
4の説明
@JsonIgnore
はちょっと特殊なのですが, Jackson
というJSONパーサライブラリを導入することで利用可能にあるアノテーションです(例のごとくこちらをどうぞ).
例えば, controller
の説明の時findAll()
とかありましたけど, これが呼び出された時, 全ての地域と各地域に紐づく市町村を全て取得するようになっています.
となると, 市町村のデータは親である地域の情報も同時に取得されます(City
クラスはArea
フィールドを持っているかつ@JoinColumn
で結合していて, getArea()
も用意されているため).
するとまた, 地域に紐付いた市町村データ全て取得する...という風に循環参照してしまうのです.
これを避けるために@JsonIgnore
アノテーションを付与することでgetArea()
はJSONに含めないようにすることができます.
getArea()
を使いたいときは直接呼び出すのがいいと思います, 多分.
これはちょっとハマったので覚えておいて憂いなしです.
Repository
repository
はDBから情報を取得・更新などをおこなう役割を担っています.
また1つピックアップして説明します.
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
が有名ですね.
そして, @Query
のvalue
を見てみると, 普通のSQLクエリが書いてありますね.
@Query
内のSQLクエリはfindByCity(cityId)
というメソッドにて発行できる, といった感じです.
ちなみに, nativeQuery = true
にしておかないと, select * from ...
の*
とかが使えません.
Service
最後に, service
についてです.
役割としては, repository
を通じてDBに処理要求をし, 結果を返却するという感じです.
以下例を示します.
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)
について説明すると, FoodRepository
でfindByCity(cityID)
にあたるSQLクエリを書いたので, それをFoodService
内で呼び出して, DBへ処理要求を出します.
あとは処理結果をそのまま返却するだけにしています.
DBから取得した値をゴニョゴニョ加工したい場合にはここでやるのもいいかと思います.
そしてcontroller
でも説明したとおり, controller
内部でservice
を@Autowired
でインジェクション(インスタンスの手動生成が不要)して, APIにアクセスがあった時に適宜DBから該当するデータ取得し, 結果を返却するという役割を担うわけです.
基本的な使い方に関しては以上になります.
Build
最後にbuild方法をさらっと.
自分はGradle
でbuildをおこなうことにしましたので, build.gradle
にbuildの設定を書きます.
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