Spring Boot + REST APIs + JPA 教學

Photo by Emile Mbunzama on Unsplash
Photo by Emile Mbunzama on Unsplash
近幾年來,Spring Boot 是相當受歡迎的後端開發 Framework。 利用 Spring 龐大的套件庫,我們幾乎可以輕鬆地達成任何的功能。開發後的程式,利用 Spring Cloud,也可以轉換成 Microservices 架構。

近幾年來,Spring Boot 是相當受歡迎的後端開發 Framework。 利用 Spring 龐大的套件庫,我們幾乎可以輕鬆地達成任何的功能。開發後的程式,利用 Spring Cloud,也可以轉換成 Microservices 架構。本章將介紹如何用 Spring Boot 建立一個後端程式,並提供 REST APIs,和串接資料庫。

本章完整的程式碼可以在 下載。

建立專案 – Spring Initializr

建立 Spring Boot 專案最簡單的方法,就是利用 Spring Initializr 線上工具。它的畫面如下:

  • Project:選擇 Maven 或是 Gradle 專案管理工具。
  • Language:選擇用 Java 或是 Kotlin 語言。
  • Spring Boot:選擇最新的 Stable 版本。
  • Project Metadata:主要是填入 Group、Artifact 和 Name。
  • Packaging:選擇編譯後打包的方式。選用 Jar 的話,會包含 Tomcat,可以直接執行。
  • Java:選擇 Java SDK 的版本。Kotlin 最終也是編譯成 Java Bytecode。
  • Dependencies:點 ADD DEPENDENCIES 按鈕新增套件。我們先新增 Spring Web 套件,它可以讓專案有 REST APIs。
Spring Boot Initializr
Spring Boot Initializr

都選好後,點擊左下方的 GENERATE 就可以下載專案,然後用 IDE 打開。IDE 的話,免費的可以用 Eclipse,付費的推薦 IntelliJ IDEA

建立 RESTful Web Services

HTTP GET Requests

讓我們來試著建立一個 REST API。首先,先新增 User

package com.waynestalk.demo.domain

data class User(val name: String?, var age: Int?)

新增 UserController,而它有兩個 Annotation:

  • @RestController:結合了 @Controller@ResponseBody 這兩個 Annotations。如果 Controller 是提供 RESTful Web Service,就會用 @RestController
  • @RequestMapping:將 Web Request 對應到某個路徑。如這邊是對應到 /users。若加在 class 上,就表示 class 裡面所有 method 都會在 /users 下面。若加在 method 上,則表示是 method 的路徑。

UserController 裡面,新增一個 GET Request Handler。

  • @GetMapping:它對應到一個 HTTP GET Request 到這個 method。我們沒有指定子路徑,所以它會使用上層 UserController 的 /users 路徑。此外,它等同於 @RequestMapping(method = [RequestMethod.GET]),只是比較短。
  • @RequestParam:如果 Request URL 是 /users?age=20,那它會解析後面的 query string。required 是指,age query param 是否必要設定。default 是指,沒設定時的預設值。
  • @GetMapping(“/{name}”):所有的 /users/XXX HTTP GET Request 都會對應到這個 method,XXX 是指任何字串。
  • @PathVariable:將 XXX 值會放入 name 參數。
package com.waynestalk.demo.controller

import com.waynestalk.demo.domain.User
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/users")
class UserController {
    private var users = listOf(
            User("Jason", 20),
            User("Alan", 22),
            User("David", 21),
            User("Monika", 20),
            User("Angela", 22)
    )

    @GetMapping
    fun getUsers(@RequestParam(required = false, defaultValue = "0") age: Int) =
            if (age == 0) users else users.filter { it.age == age }

    @GetMapping("/{name}")
    fun getUserBy(@PathVariable name: String) =
            users.find { it.name == name }
                    ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User $name not found")
}

執行專案後,可以用瀏覽器直接開 http://localhost:8080/users,就可以取得所有 User:

[
   {
      "name":"Jason",
      "age":20
   },
   {
      "name":"Alan",
      "age":22
   },
   {
      "name":"David",
      "age":21
   },
   {
      "name":"Monika",
      "age":20
   },
   {
      "name":"Angela",
      "age":22
   }
]

也可以在命令列上,用 curl 指令發出 HTTP GET Request。

% curl --location --request GET 'http://localhost:8080/users'
[{"name":"Jason","age":20},{"name":"Alan","age":22},{"name":"David","age":21},{"name":"Monika","age":20},{"name":"Angela","age":22}]

搜尋 age 是 20 的 User。

% curl --location --request GET 'http://localhost:8080/users?age=20'
[{"name":"Jason","age":20},{"name":"Monika","age":20}]

取得 User Monika。

% curl --location --request GET 'http://localhost:8080/users/Monika'
{"name":"Monika","age":20}

HTTP POST Requests

接下來,我們新增一個 REST API 來新增 User。在 UserController 裡新增一個 POST Request Handler。

  • @PostMapping: 它等同於 @RequestMapping(method = [RequestMethod.POST])
  • @RequestBody:它會將 Web Request 的 Body 轉換成 User 的結構。它會根據 Web Request Headers 中的 Content-Type 來決定要用哪個 HttpMessageConverter 來解析 Body。
@RestController
@RequestMapping("/users")
class UserController {
    @PostMapping
    fun addUser(@RequestBody user: User): User {
        if (users.find { it.name == user.name } != null)
            throw ResponseStatusException(HttpStatus.CONFLICT, "Duplicated user ${user.name}")
        users.add(user)
        return user
    }
}

用 curl 指令來新增 User Sofia。

% curl --location --request POST 'http://localhost:8080/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "Sofia",
    "age": 10
}'

HTTP PUT Requests

一般來說,新增東西的話會用 POST Request,而修改東西的話就會用 PUT Request。其用法大同小異。

@RestController
@RequestMapping("/users")
class UserController {
    @PutMapping("/{name}")
    fun modifyUser(@PathVariable name: String, @RequestBody user: User): User {
        val found: User = users.find { it.name == name }
                ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User $name not found")
        found.age = user.age
        return found
    }
}

用 curl 來修改 User Monika。

% curl --location --request PUT 'http://localhost:8080/users/Monika' \
--header 'Content-Type: application/json' \
--data-raw '{
    "age": 24
}'

HTTP DELETE Requests

最後要介紹的 HTTP Request 就是 DELETE – 刪除。

@RestController
@RequestMapping("/users")
class UserController {
    @DeleteMapping("/{name}")
    fun removeUser(@PathVariable name: String): User {
        val found = users.find { it.name == name }
                ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User $name not found")
        users.remove(found)
        return found
    }
}

用 curl 刪除 User Monika。

% curl --location --request DELETE 'http://localhost:8080/users/Monika'

REST API 命名規格

上面介紹了 4 種 HTTP Request。但是只用到 2 種 URI,一個是 /users,另外一個是 /users/{name}。我們可能會想說,為何不每一種 HTTP Request 有唯一的 URI 呢?像是這樣:

  • GET:/users/get、/users/{name}/get
  • POST:/users/add
  • PUT:/users/{name}/update、/users/update 並將 name 指定在 body 中
  • DELETE:/users/{name}/delete、/users/delete 並將 name 指定在 body 中

這在 REST API Naming Convention 有詳細的解說。簡短來說,REST API 要以 Resources 為主,而不是以 Actions 為主。也就是說,像 /users/Monika 這個 Resource,它提供 3 個 Actions,讓我們可以取得資料 (GET)、更新 (PUT)、和移除 (DELETE)。而 /users Resource 提供 2 個 Actions,讓我們可以取得列表和新增。

串連資料庫 – JPA

JPA 是 Java Persistence API 的縮寫。它無法直接使用,而是定義了一套 API,讓其他的 Framework 可以實作。所以用 JPA 的話,我們可以替換底層的資料庫,而不需要改動程式。

JPA 使用 Object Relational Mapping (ORM) 的架構。所謂的 ORM,簡單來說,就是資料表和資料表欄位會映射到對應的物件 (Object)。因此在使用時,開發者面對的是物件,而不是資料庫。所以開發起來就相對地輕鬆快速,而且也不太需要直接撰寫 SQL。

H2

H2 是一個 In-Memory 資料庫。在專案初期,或是在撰寫測試程式時,In-Memory 資料庫總是首選。

要使用 H2 的話,在建立專案時,引入 Spring Data JPA 和 H2 Database 套件。

Spring Boot + H2
Spring Boot + H2

加入那兩個 Dependencies 後,你會發現在 build.gradle.kts 會多出下面這幾行:

// build.gradle.kts

plugins {
    kotlin("plugin.jpa") version "1.3.72"
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    runtimeOnly("com.h2database:h2")
}

引用套件後,接下來我們要修改程式,將 User 列表存入資料庫。

首先,將 User 修改成如下。裡面多了一些 Annotations。

  • @Entity:設定 class 為 Entity。這樣 class User 就會對應到 user 資料表。
  • @Column:當 class 被設定為 Entity 時,裡面的每一個 field 都會對應到資料表的 Column。而 @Column 可以對資料表 Column 做些設定。例如,這邊我們設定 name 的值必須唯一 – Column(unique = true)
  • @Id:設定這個 Column 是這個資料表的 Primary Key。
  • @GeneratedValue:讓 @Id Column 的值在 insert 時會自動產生。
package com.waynestalk.demo.domain

import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.Id

@Entity
data class User(
    @Column(unique = true) val name: String?,
    var age: Int?,
    @Id @GeneratedValue var id: Long? = null)

再來新增 UserRepository 來存取資料庫。UserRepository 本身是 interface,而且繼承 JpaRepository(而它是繼承 Repository)。Spring 會掃描所有繼承 Repositoryinterface,並自動產生相對應的 classJpaRepository 已經定義了不少資料庫存取的 method,Spring 都會自動產生。

此外,JPA 有一套命名的機制,依據那套機制命名的 method,Spring 都會自動產生相對應的程式碼。就如下中,我們自定義了兩個搜尋的 method。那套命名機制在 Query Creation 有詳細的解釋。

package com.waynestalk.demo.repository

import com.waynestalk.demo.domain.User
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

interface UserRepository : JpaRepository<User, Long> {
    fun findAllByAge(age: Int): List<User>

    fun findByName(name: String): User?
}

最後就是修改 UserController,讓它存取資料庫。

package com.waynestalk.demo.controller

import com.waynestalk.demo.domain.User
import com.waynestalk.demo.repository.UserRepository
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*
import org.springframework.web.server.ResponseStatusException

@RestController
@RequestMapping("/users")
class UserController(private val userRepository: UserRepository) {
    @GetMapping
    fun getUsers(@RequestParam(required = false, defaultValue = "0") age: Int): List<User> =
            if (age == 0) userRepository.findAll() else userRepository.findAllByAge(age)

    @GetMapping("/{name}")
    fun getUserBy(@PathVariable name: String) =
            userRepository.findByName(name)
                    ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User $name not found")

    @PostMapping
    fun addUser(@RequestBody user: User) = userRepository.save(user)

    @PutMapping("/{name}")
    fun modifyUser(@PathVariable name: String, @RequestBody user: User): User {
        val found = userRepository.findByName(name)
                ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User $name not found")
        if (user.age != null) {
            found.age = user.age
        }
        userRepository.save(found)
        return found
    }

    @DeleteMapping("/{name}")
    fun removeUser(@PathVariable name: String): User {
        val user = userRepository.findByName(name)
                ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User $name not found")
        userRepository.delete(user)
        return user
    }
}

到這邊為止,程式碼已經修改完成。不過,之前我們一些預設的幾個 User 資料,現在都被移除了。為了方便測試,我們用下面的程式碼,在程式一啟動時,預先插入一些 User 到資料庫。

package com.waynestalk.demo

import com.waynestalk.demo.domain.User
import com.waynestalk.demo.repository.UserRepository
import org.springframework.boot.ApplicationRunner
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class UserConfiguration {
    @Bean
    fun initUsers(userRepository: UserRepository) = ApplicationRunner {
        userRepository.saveAll(listOf(
                User("Jason", 20, 1),
                User("Alan", 22, 2),
                User("David", 21, 3),
                User("Monika", 20, 4),
                User("Angela", 22, 5)
        ))
    }
}

MySQL

MySQL 是相當受歡迎的免費資料庫系統。功能強大,許多商業程式也都是使用 MySQL。一般來說,如果公司很有錢,就會使用 Oracle。而個人和小公司就會選用 MySQL。

要使用 MySQL 的話,就要加入 Spring Data JPA 和 MySQL Driver 這兩個套件。

Spring Boot + MySQL
Spring Boot + MySQL

套件引入後,build.gradle.kts 多了下面這幾行。

// build.gradle.kts

plugins {
    kotlin("plugin.jpa") version "1.3.72"
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    runtimeOnly("mysql:mysql-connector-java")
}

最後在 application.properties 要加上 DataSource 的設定。

// application.properties
spring.datasource.url=jdbc:mysql://localhost:3306/dbname?useUnicode=true&characterEncoding=utf-8&autoReconnect=true&serverTimezone=Asia/Taipei
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb

資料庫的轉換就完成了。程式碼的部分,拖 JPA 的福,不需要改動!

結語

剛剛接觸 Spring Boot 的話,一定會覺得 Spring Boot 很難。這是因為它有很多的 Annotations 要去了解,但也是因為這些 Annotations 讓 Spring Boot 的程式碼很小。例如,你不需要寫程式設定某個函式來處理某個 HTTP Request,而是透過 Annotation 宣告即可。Spring Boot 提供很多的 Annotations 來幫我們簡化這類的程式碼。熟悉後,你會覺得它相當地方便!

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

You May Also Like
Photo by Charles Jackson on Unsplash
Read More

Springdoc-OpenAPI 教學

Springdoc 是一個整合 OpenAPI Specification 和 Spring Boot 的套件。和 SpringFox 套件一樣,它產出 Swagger 文件。兩者不同在於,Springdoc 是用 Swagger 3,而 SpringFox 是用 Swagger 2。
Read More