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ではアプリのロジックを分割して書く必要がある。
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 の短所:
このチュートリアルは簡単のため、本来であれば推奨されるいくつかの作業をしていない。 この例を参考に本格的なアプリを作る場合は、以下の改良点を参考のこと: