PostgreSQL + Spring Boot + Kotlin を使った To Do (やること管理) アプリのバックエンド。 以下のAPIエンドポイントを提供する:
GET /todos を実行すると、データベースにある Todo 項目すべてを JSON形式で返す。
[{"id":2, "text":"Buy milk."}, {"id":5, "text":"Clean up."}]
POST /todos を実行すると、与えられた Todo をデータベースに追加する。
このとき、新しく追加された Todo ID を返す。
{"text": "Hello!"}
GET /todos/ID を実行すると、与えられた IDをもつ Todo 項目ひとつをJSON形式で返す。
{"id":2, "text":"Buy milk."}
DELETE /todos/ID を実行すると、与えられた IDをもつ Todo 項目をデータベースから削除する。
$ brew services info postgresql postgresql@14 (homebrew.mxcl.postgresql@14) Running: ✔ Loaded: ✔ Schedulable: ✘
$ createdb tododb
spring.application.name=todoApp spring.datasource.url=jdbc:postgresql://localhost/tododb spring.datasource.username=postgres spring.datasource.password=postgres spring.datasource.driverClassName=org.postgresql.Driver
<!DOCTYPE html> <html> <body> Welcome! </body> </html>
xxx.java) をコンパイルすると、
classファイル (xxx.class) が生成される。
jarファイル (xxx.jar) は複数のclassファイルをまとめたもので、
JVM (Java Virtual Machine) と呼ばれる仮想マシンで実行する。
xxx.kt) をclassファイルにコンパイルし、
Javaと同じく JVMで実行できる。
@ClassAnnotation
class MyClass {
@MethodAnnotation
fun foo() {
@VariableAnnotation
val x = 123
}
}
IntelliJ を使わず、コマンドラインからアプリのビルド・起動をする場合は、 以下のようにする:
$ ./gradlew build (アプリのビルドおよびテスト) Starting a Gradle Daemon, 1 stopped Daemon could not be reused, use --status for details ... BUILD SUCCESSFUL in 11s 9 actionable tasks: 5 executed, 4 up-to-date $ ./gradlew bootrun (アプリの実行) > Task :bootRun . ____ _ __ _ _ /∖∖ / ___'_ __ _ _(_)_ __ __ _ ∖ ∖ ∖ ∖ ( ( )∖___ | '_ | '_| | '_ ∖/ _` | ∖ ∖ ∖ ∖ ∖∖/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____| .__|_| |_|_| |_∖__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot :: (v3.3.0) ...
package com.example.todoApp
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.*
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
class TodoAppApplicationTests {
@Test
fun contextLoads() {
}
@Test
fun `最初のテスト`() {
assertThat(1+2, equalTo(3))
}
}
@Test アノテーションがついているメソッドは、テストとして実行される。
fun `~` の部分には、テスト名を書く。
assertThat(1+2, equalTo(3)) は「1+2 の結果が 3 に等しい」ことをチェックしている。
コード中の赤色になっている部分で Option + Return を押すと、 その問題を解決するための手段を提案してくれる。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class TodoAppApplicationTests(@Autowired val restTemplate: TestRestTemplate, @LocalServerPort val port: Int) { ...@Test fun `GETリクエストはOKステータスを返す`() { // localhost/todos に GETリクエストを発行する。 val response = restTemplate.getForEntity("http://localhost:$port/todos", String::class.java) // レスポンスのステータスコードは OK である。 assertThat(response.statusCode, equalTo(HttpStatus.OK)) }...
Springでは
@Autowired や @LocalServerPort のようなアノテーションを書くことによって、
実行時に必要な値やオブジェクトをフレームワークが自動的に作成・注入してくれるようになっている。
この機能を「依存注入 (Dependency Injection, DI)」と呼ぶ。
Spring では、コントローラーやデータベース接続など、アプリの動作に関連するほとんどのオブジェクトは
DI を使って作成させる (ビジネスロジックに関連するオブジェクトを除く)。
package com.example.todoApp
...
@RestController
class TodoController {
@GetMapping("/todos")
fun getTodos(): String {
return "Here are todos!"
}
}
Springのコードにおいては、ファイル先頭のパッケージ宣言
package com.example.todoAppは非常に重要である。 Springの依存注入は、同一パッケージ内のクラスをスキャンすることによって行うので、 パッケージ宣言を忘れると「あるはずのコンポーネントが見つからない」 「宣言したはずのメソッドが動作しない」という状況が発生する。
Springにおける
@RestController や @GetMapping のようなアノテーションは、
依存注入のもう一方の側面を表している。
ここまでのコードでは明示的に TodoController を作成するロジックは存在していない。
にもかかわらず、TodoController オブジェクトが作成され、動作しているように見える。
ここでは、@RestController は、当該クラスが Spring Boot の DI候補として
利用可能であることを示す。さらに @GetMapping("/todos") は、当該メソッドが
GET /todos リクエストに対するハンドラとして利用可能であることを示す。
この2つのアノテーションが存在することによって、
Spring Boot は getTodos() メソッドの周囲に
HTTP リクエスト・レスポンスを処理する機構をくっつけ全体を Webサーバとして動作させる。
この仕組みを使って、数行で新しい Web API を作成することができる。
resources フォルダの下に db/migration というフォルダを作成し、
ここにファイルを新規作成する:
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
text TEXT
);
一般に、アプリのビジネスロジックはデータベースの構造に依存する。 さらに、アプリを開発するにつれてデータベース構造は変化していく。 このため、データベースの変更履歴を管理する「マイグレーションツール」を使って 開発するのが一般的である。ここでは Spring との統合が容易な Flyway というツールを使っている。
Vxxx__yyy.sql のような形式にすること (先頭の V は大文字)。
$ dropdb tododb $ createdb tododb
$ psql tododb
psql (14.12 (Homebrew))
Type "help" for help.
tododb=# \d
List of relations
Schema | Name | Type | Owner
--------+-----------------------+----------+----------
public | flyway_schema_history | table | postgres
public | todos | table | postgres
public | todos_id_seq | sequence | postgres
(3 rows)
TRUNCATE TABLE todos; INSERT INTO todos VALUES (1, 'foo'); INSERT INTO todos VALUES (2, 'bar');
package com.example.todoApp data class Todo(val id: Long, val text: String)
Kotlin における data class は、データの格納に使うクラスを定義するためのショートカットである。 通常であれば、以下のようなクラス定義を書く必要があるが、data class の定義は自動でこれらを追加してくれる。
class Todo {
val id: Long
val text: String
constructor(id: Long, text: String) {
this.id = id
this.text = text
}
getId(): Long {
return id
}
getText(): String {
return text
}
...
}
package com.example.todoApp ... @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Sql("/insert_test_data.sql") class TodoAppApplicationTests ...@Test fun `GETリクエストはTodoオブジェクトのリストを返す`() { // localhost/todos に GETリクエストを送り、レスポンスを Todoオブジェクトの配列として解釈する。 val response = restTemplate.getForEntity("http://localhost:$port/todos", Array<Todo>::class.java) // レスポンスの Content-Type は application/json であること。 assertThat(response.headers.contentType, equalTo(MediaType.APPLICATION_JSON)) // 配列は2つの要素をもつこと。 val todos = response.body!! assertThat(todos.size, equalTo(2)) // 最初の要素は id=1 であり、text が "foo" であること。 assertThat(todos[0].id, equalTo(1)) assertThat(todos[0].text, equalTo("foo")) // 次の要素は id=2 であり、text が "bar" であること。 assertThat(todos[1].id, equalTo(2)) assertThat(todos[1].text, equalTo("bar")) }...
ボタンを押してテストを実行し、失敗することを確認する。
package com.example.todoApp ... @Component class TodoRowMapper : RowMapper{ override fun mapRow(rs: ResultSet, rowNum: Int): Todo { return Todo(rs.getLong(1), rs.getString(2)) } } @Repository class TodoRepository( @Autowired val jdbcTemplate: JdbcTemplate, @Autowired val todoRowMapper: TodoRowMapper ) { fun getTodos(): List<Todo> { return jdbcTemplate.query("SELECT id, text FROM todos", todoRowMapper) } }
コード中のクラス名・関数名・変数名などを Command ⌘ キーを押しながらクリックすると、 そのクラスや関数の定義箇所に移動する。
package com.example.todoApp ... @RestController class TodoController(val todoRepository: TodoRepository) { ...@GetMapping("/todos") fun getTodos(): List<Todo> { return todoRepository.getTodos() }...
TodoAppApplicationTests に戻り、 ボタンを押してテストを実行し、成功することを確認する。
なぜ依存注入が必要なのか?なぜ Spring が「依存注入」「IoCコンテナ」などという概念を使っているのかというと、 それはコードを変えずにシステムのふるまいを変更するためである。 たとえば、以下のようなコードを考えてみよう:
name = "tododb" db = PostgreSQL(name) db.query("SELECT * FROM todos")このコードでは、ふるまいは完全に固定されている。 データベース名を変更したり、PostgreSQL 以外のデータベースを使うためには コードを変更する必要がある。しかしテストの際には できるだけ本番コードそのものをテストしたほうがよい。 上のようなコードではテストが面倒である。 そこで Spring を使ったプログラミングでは、コードの各部分を分解し、 フレームワークに「配管作業」を任せている。そしてこの接続を 切り替えられるようにしておくことで、システムの柔軟性を実現している。 この配管作業が依存注入に相当する。
依存注入のイメージ (実線は有効な接続)Spring における依存注入や機能拡張は、基本的にアプリケーション ロジックの各箇所に Springのコードを挿入することによって行われる。 このために、Springではアプリのロジックを分割して書く必要がある。
Spring による機能拡張のイメージ
package com.example.todoApp data class TodoRequest(val text: String)
@Test
fun `POSTリクエストはOKステータスを返す`() {
// localhost/todos に POSTリクエストを送る。このときのボディは {"text": "hello"}
val request = TodoRequest("hello")
val response = restTemplate.postForEntity("http://localhost:$port/todos", request, String::class.java)
// レスポンスのステータスコードは OK であること。
assertThat(response.statusCode, equalTo(HttpStatus.OK))
}
Kotlinファイル内で Java言語のコードをペーストすると、 IntelliJ は自動的に Java → Kotlin に変換してくれる。 これはJavaのサンプルコードをKotlinで使いたいときに役に立つ。 たとえば、以下のJavaコードを上記のメソッド内にペーストしてみよう:
TodoRequest request = new TodoRequest("hello");
ResponseEntity<String> response = restTemplate.postForEntity("http://localhost:"+port+"/todos", request, String.class);
ヒント:
POSTリクエストを処理するためには @GetMapping ではなく @PostMapping を使う。
また、このとき @RequestBody を使って
ボディとして受け取った JSON を TodoRequestオブジェクトに変換できる。
@PostMapping("/todos")
fun saveTodo(@RequestBody todoRequest: TodoRequest): String {
...
}
GET /todos を実行し、得られた Todoのリストを記憶しておく。
POST /todos でなにか適当な Todoをひとつ追加する。
GET /todos を実行し、得られた Todoリストに
新しい Todo項目が追加されているかどうかをチェックする。
@Test
fun `POSTリクエストはTodoオブジェクトを格納する`() {
// localhost/todos に GETリクエストを送り、レスポンスを Todoオブジェクトの配列として解釈する。
...
// このときのレスポンスを todos1 として記憶。
...
// localhost/todos に POSTリクエストを送る。このときのボディは {"text": "hello"}
...
// ふたたび localhost/todos に GETリクエストを送り、レスポンスを Todoオブジェクトの配列として解釈する。
...
// このときのレスポンスを todos2 として記憶。
...
// 配列 todos2 は、配列 todos1 よりも 1 要素だけ多い。
assertThat(todos2.size, equalTo(todos1.size + 1))
// 配列 todos2 には "hello" をもつTodoオブジェクトが含まれている。
assertThat(todos2.map { todo: Todo -> todo.text }, hasItem("hello"))
}
ヒント:
データベースへの記録には jdbcTemplate.update メソッドを使う。
このとき SQL文中における「?」の部分が引数の値に展開される。
jdbcTemplate.update("INSERT INTO todos (text) VALUES (?)", todoRequest.text)
注意: "Java Bean" という用語もあるが、 これは特定の規則に従って作られた Javaオブジェクトのことをさし、 Spring Beanとは別物。
@Controller (または @RestController) … Web APIのエンドポイントを提供する。
@Repository … データの保存・変更・削除等の機能を提供する。
@Service … ビジネスロジックに関連した機能を提供する。
@Component … 上記以外の目的で使われる。
コードがうまく動かない場合、以下の2つの方法で問題を特定する:
... logging.level.org.springframework.web=DEBUG logging.level.org.springframework.jdbc.core=DEBUG
アプリの実行時にターミナルに現れる Spring Bootのログは、 何かイベントが発生するたびに記録される。 ログの各行は以下のような書式になっている。
注意: 通常は DEBUGレベルまたはTRACEレベルのログは出力すべきでない (出力量が膨大なため)。
c.e = com.example
.s.d.r.c = (org).spring.data.repository.config
o.a.c.c = org.apache.catalina.core
. ____ _ __ _ _ /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ \\/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____| .__|_| |_|_| |_\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot :: (v3.3.0) 2024-06-11T10:08:17.591+09:00 INFO 17122 --- [ Test worker] c.e.todoApp.TodoAppApplicationTests : Starting TodoAppApplicationTests using Java 21.0.3 with PID 17122 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^ ^^^^^ ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ タイムスタンプ ログ プロセス スレッド名 モジュール名 (c.e = com.example) メッセージ レベル ID 2024-06-11T10:08:17.591+09:00 INFO 17122 --- [ Test worker] c.e.todoApp.TodoAppApplicationTests : No active profile set, falling back to 1 default profile: "default" 2024-06-11T10:08:17.868+09:00 INFO 17122 --- [ Test worker] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JDBC repositories in DEFAULT mode. 2024-06-11T10:08:17.879+09:00 INFO 17122 --- [ Test worker] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 8 ms. Found 0 JDBC repository interfaces. 2024-06-11T10:08:18.120+09:00 INFO 17122 --- [ Test worker] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port 0 (http) (Tomcat Webサーバを起動) 2024-06-11T10:08:18.126+09:00 INFO 17122 --- [ Test worker] o.apache.catalina.core.StandardService : Starting service [Tomcat] 2024-06-11T10:08:18.126+09:00 INFO 17122 --- [ Test worker] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.24] 2024-06-11T10:08:18.154+09:00 INFO 17122 --- [ Test worker] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext 2024-06-11T10:08:18.155+09:00 INFO 17122 --- [ Test worker] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 553 ms 2024-06-11T10:08:18.265+09:00 INFO 17122 --- [ Test worker] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting... 2024-06-11T10:08:18.353+09:00 INFO 17122 --- [ Test worker] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@77c1e611 (Hikari Connection Pool が PostgreSQLに接続) 2024-06-11T10:08:18.354+09:00 INFO 17122 --- [ Test worker] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed. 2024-06-11T10:08:18.377+09:00 INFO 17122 --- [ Test worker] org.flywaydb.core.FlywayExecutor : Database: jdbc:postgresql://localhost/tododb (PostgreSQL 14.12) 2024-06-11T10:08:18.388+09:00 INFO 17122 --- [ Test worker] o.f.c.i.s.JdbcTableSchemaHistory : Schema history table "PUBLIC"."flyway_schema_history" does not exist yet 2024-06-11T10:08:18.390+09:00 INFO 17122 --- [ Test worker] o.f.core.internal.command.DbValidate : Successfully validated 1 migration (execution time 00:00.005s) 2024-06-11T10:08:18.393+09:00 INFO 17122 --- [ Test worker] o.f.c.i.s.JdbcTableSchemaHistory : Creating Schema History table "PUBLIC"."flyway_schema_history" ... 2024-06-11T10:08:18.412+09:00 INFO 17122 --- [ Test worker] o.f.core.internal.command.DbMigrate : Current version of schema "PUBLIC": << Empty Schema >> 2024-06-11T10:08:18.414+09:00 INFO 17122 --- [ Test worker] o.f.core.internal.command.DbMigrate : Migrating schema "PUBLIC" to version "1 - create todos table" 2024-06-11T10:08:18.422+09:00 INFO 17122 --- [ Test worker] o.f.core.internal.command.DbMigrate : Successfully applied 1 migration to schema "PUBLIC", now at version v1 (execution time 00:00.002s) (Flyway がマイグレーションを version 1 まで完了) 2024-06-11T10:08:18.482+09:00 INFO 17122 --- [ Test worker] o.s.b.a.w.s.WelcomePageHandlerMapping : Adding welcome page: class path resource [static/index.html] 2024-06-11T10:08:18.511+09:00 DEBUG 17122 --- [ Test worker] s.w.s.m.m.a.RequestMappingHandlerMapping : 7 mappings in 'requestMappingHandlerMapping' 2024-06-11T10:08:18.563+09:00 DEBUG 17122 --- [ Test worker] o.s.w.s.handler.SimpleUrlHandlerMapping : Patterns [/webjars/**, /**] in 'resourceHandlerMapping' (各リクエストURLの登録が完了) 2024-06-11T10:08:18.581+09:00 DEBUG 17122 --- [ Test worker] s.w.s.m.m.a.RequestMappingHandlerAdapter : ControllerAdvice beans: 0 @ModelAttribute, 0 @InitBinder, 1 RequestBodyAdvice, 1 ResponseBodyAdvice 2024-06-11T10:08:18.595+09:00 DEBUG 17122 --- [ Test worker] .m.m.a.ExceptionHandlerExceptionResolver : ControllerAdvice beans: 0 @ExceptionHandler, 1 ResponseBodyAdvice 2024-06-11T10:08:18.697+09:00 INFO 17122 --- [ Test worker] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 58245 (http) with context path '/' 2024-06-11T10:08:18.702+09:00 INFO 17122 --- [ Test worker] c.e.todoApp.TodoAppApplicationTests : Started TodoAppApplicationTests in 1.221 seconds (process running for 1.786) 2024-06-11T10:08:19.317+09:00 INFO 17122 --- [o-auto-1-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet' 2024-06-11T10:08:19.317+09:00 INFO 17122 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet' 2024-06-11T10:08:19.317+09:00 DEBUG 17122 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : Detected StandardServletMultipartResolver 2024-06-11T10:08:19.317+09:00 DEBUG 17122 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : Detected AcceptHeaderLocaleResolver 2024-06-11T10:08:19.317+09:00 DEBUG 17122 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : Detected FixedThemeResolver 2024-06-11T10:08:19.317+09:00 DEBUG 17122 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : Detected org.springframework.web.servlet.view.DefaultRequestToViewNameTranslator@563392e5 2024-06-11T10:08:19.318+09:00 DEBUG 17122 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : Detected org.springframework.web.servlet.support.SessionFlashMapManager@244f356 2024-06-11T10:08:19.318+09:00 DEBUG 17122 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : enableLoggingRequestDetails='false': request parameters and headers will be masked to prevent unsafe logging of potentially sensitive data 2024-06-11T10:08:19.318+09:00 INFO 17122 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms 2024-06-11T10:08:19.321+09:00 DEBUG 17122 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : GET "/todos/2", parameters={} (GETリクエストを受け取った) 2024-06-11T10:08:19.326+09:00 DEBUG 17122 --- [o-auto-1-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.todoApp.TodoController#getTodo(long) 2024-06-11T10:08:19.366+09:00 DEBUG 17122 --- [o-auto-1-exec-1] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL query 2024-06-11T10:08:19.367+09:00 DEBUG 17122 --- [o-auto-1-exec-1] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL statement [SELECT id, text FROM todos WHERE id=?] (SQL文を実行した) 2024-06-11T10:08:19.386+09:00 DEBUG 17122 --- [o-auto-1-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor : Using 'application/json', given [*/*] and supported [application/json, application/*+json] 2024-06-11T10:08:19.387+09:00 DEBUG 17122 --- [o-auto-1-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor : Writing [Todo(id=2, text=bar)] 2024-06-11T10:08:19.392+09:00 DEBUG 17122 --- [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : Completed 200 OK (ステータスコード200でレスポンスを返した) 2024-06-11T10:08:19.403+09:00 DEBUG 17122 --- [ Test worker] o.s.web.client.DefaultRestClient : Reading to [com.example.todoApp.Todo]
Spring Boot の長所:
Spring Boot の短所:
このチュートリアルは簡単のため、本来であれば推奨されるいくつかの作業をしていない。 この例を参考に本格的なアプリを作る場合は、以下の改良点を参考のこと: