「はじめての Django アプリ作成」をSpring WebFluxでやってみた、その 2

# はじめに
この記事はDjangoの公式チュートリアル[「はじめての Django アプリ作成」](https://docs.djangoproject.com/ja/4.0/intro/tutorial02/)と同じものをSpring WebFluxで作成することでDjangoとSpring WebFluxの違いを理解することを目的としています。

# 違い一覧
DjangoとSpringで類似している要素を纏めてみました。

|Django|Spring|備考|
|:-----------|:------------|:------------|
|Model(モデル)|Model(モデル)||
|Template(テンプレート)|View(ビュー)||
|View(ビュー)|Controller(コントローラ)||
|SQLite3|H2 Database|基本のデータベース|
|Django Admin|無し|CMS支援|

# はじめてのアプリ作成、その 2

## Database の設定

### Django
`mysite/settings.py`の`ENGINE`に`django.db.backends.sqlite3`と定義されている通り、デフォルトでSQLite3を使います。データは`db.sqlite3`というファイルに保存されます。

```python:mysite/settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}
```

### Spring Webflux
Springでは[H2 Database](https://www.h2database.com/html/main.html)というJavaプラットフォーム上で動作するRDBSをDjangoのsqlite3のような感じで使うみたいです。一般的にDBへの連結は[JPA(Java Persistence API)](https://spring.io/projects/spring-data-jpa)というモジュールが役を担います。
しかし、JPAはRDBSの性質上ブロッキングIO方式なのでリアクティブなWebFluxの長所を生かしきれません。そこで登場するのが[R2DBC(Reactive Relational Database Connectivity)](https://spring.io/projects/spring-data-r2dbc)です。まだ新しいプロジェクトですがリアクティブ向けのRDBSサポートを目標としています。
`H2 Database`と`R2DBC`と使うためには下記のようにbuild.gradleに依存関係を記述します。

```gradle:build.gradle
...

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
	implementation 'org.springframework.boot:spring-boot-starter-webflux'
	runtimeOnly 'com.h2database:h2'
    runtimeOnly 'io.r2dbc:r2dbc-h2'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'io.projectreactor:reactor-test'
}

...
```
H2データベースは基本的にインメモリーDBとして動作しますが、設定でデータをファイルとして永続化できます。今回は設定なしでインメモリーDBとして使用します。

## モデルの作成

### Django
Djangoではアプリの`models.py`に`models.Model`のサブクラスを定義し`migrate`することでデータモデルを作成することができます。

#### 1. `models.py`にモデルを定義する。 

```python:polls/models.py
from django.db import models


class Question(models.Model):
    question_text = models.CharField(max_length=200)
    pub_date = models.DateTimeField('date published')


class Choice(models.Model):
    question = models.ForeignKey(Question, on_delete=models.CASCADE)
    choice_text = models.CharField(max_length=200)
    votes = models.IntegerField(default=0)
```

#### 2. (もし追加していなかったら）`mysite/settings.py`にアプリを登録する。 


```python:mysite/settings.py
...

INSTALLED_APPS = [
    'polls.apps.PollsConfig',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

...
```

#### 3. モデルの変更を読み取り差分をマイグレーションファイルにする

```bash
python manage.py makemigrations polls
```

#### 4. DBに反映する

```bash
python manage.py migrate
```

### Spring Webflux
現在のR2DBCには自動的にスキーマを定義してくれる機能はなく、データモデルのクラスとスキーマ両方を定義する必要があります。

#### 1. スキーマファイルを作る
データベーススキーマを初期化するため`src/main/resources/schema.sql`にスキーマを定義します。spring-bootはresourcesの最上段に位置する`schema.sql`や`data.sql`ファイルを発見し自動に初期化します[^1]。

:::note warn
公式のガイド（https://spring.pleiades.io/guides/gs/accessing-data-r2dbc/）のようにschema.sqlをinitializerでもう一度初期化しようとすると"Table already exists"エラーが発生します。
:::

```sql:src/main/resources/schema.sql
BEGIN;
--
-- Create model Question
--
CREATE TABLE question (
    id serial NOT NULL PRIMARY KEY,
    question_text varchar(200) NOT NULL,
    pub_date timestamp with time zone NOT NULL
);
--
-- Create model Choice
--
CREATE TABLE choice (
    id serial NOT NULL PRIMARY KEY,
    choice_text varchar(200) NOT NULL,
    votes integer NOT NULL,
    question_id integer NOT NULL
);
--
-- Add constraint
--
ALTER TABLE choice
  ADD CONSTRAINT choice_question_id_c5b4b260_fk_polls_question_id
    FOREIGN KEY (question_id)
    REFERENCES question (id);

CREATE INDEX choice_question_id_c5b4b260 ON choice (question_id);

COMMIT;
```
今回はDjangoのSQLをそのまま流用します。

#### 2. エンティティを定義する
Djangoプロジェクトと一貫性を取るため`src/main/java/com/example/mysite/polls`の下に`models`というディレクトリーを作り、`Question`と`Choice`のエンティティ定義します。

```java:polls/models/Question.java
package com.example.mysite.polls.models;

import java.time.ZonedDateTime;
import org.springframework.data.annotation.Id;

public class Question {

    @Id
    private Long id;

    private final String question_text;

    private final ZonedDateTime pub_date;

    public Question(String question_text, ZonedDateTime pub_date) {
        this.question_text = question_text;
        this.pub_date = pub_date;
    }

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

    public void setId(Long id) {
        this.id = id;
    }

    public String getQuestionText() {
        return this.question_text;
    }

    public ZonedDateTime getPubDate() {
        return this.pub_date;
    }

    @Override
    public String toString() {
        return String.format(
            "Question[id=%d, question_text='%s', pub_date='%s']",
            id, question_text, pub_date);
    }
}
```

```java:polls/models/Choice.java
package com.example.mysite.polls.models;

import org.springframework.data.annotation.Id;

public class Choice {

    @Id
    private Long id;

    private final String choice_text;

    private final Integer votes;

    private final Long question_id;

    public Choice(String choice_text, Integer votes, Long question_id) {
        this.choice_text = choice_text;
        this.votes = votes;
        this.question_id = question_id;
    }

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

    public void setId(Long id) {
        this.id = id;
    }

    public String getChoiceText() {
        return this.choice_text;
    }

    public Integer getVotes() {
        return this.votes;
    }

    public Long getQuestionId() {
        return this.question_id;
    }

    @Override
    public String toString() {
        return String.format(
            "Choice[id=%d, choice_text='%s', votes='%s', question_id='%s']",
            id, choice_text, votes, question_id);
    }
}
```

#### 3. エンティティを操作する
R2DBCではSpring Dataの抽象化である`ReactiveCrudRepository`を使うかR2DBCの`R2dbcEntityTemplate`を使うことができます[^2]。下記の例は`R2dbcEntityTemplate`を使った場合です。

```java:MysiteApplication.java
package com.example.mysite;

import com.example.mysite.polls.models.Question;
import io.r2dbc.spi.ConnectionFactory;
import java.time.ZonedDateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.data.r2dbc.connectionfactory.init.ConnectionFactoryInitializer;
import org.springframework.data.r2dbc.connectionfactory.init.ResourceDatabasePopulator;
import reactor.core.publisher.Mono;


@SpringBootApplication
public class MysiteApplication {

	private static final Logger log = LoggerFactory.getLogger(MysiteApplication.class);

	public static void main(String[] args) {
		SpringApplication.run(MysiteApplication.class, args);
	}

    @Bean
    public CommandLineRunner demo(ConnectionFactory connectionFactory) {

        return (args) -> {
        	R2dbcEntityTemplate template = new R2dbcEntityTemplate(connectionFactory);

            log.info("--------------1-----------------");

		    Mono<Question> saved = template.insert(Question.class)
		      						.using(new Question("What's new?", ZonedDateTime.now()));

		    saved.subscribe(obj -> log.info(obj.toString()));	 

			log.info("--------------2-----------------");

		    template.select(Question.class)
		      .first()
		      .doOnNext(it -> log.info(it.toString()))
		      .subscribe();

            log.info("--------------3-----------------");
        };
    }
}

```

`./gradlew bootrun`で実行すると以下のようなログが出力されます。


```text:output
INFO 82641 --- [           main] com.example.mysite.MysiteApplication     : Starting MysiteApplication using Java 17.0.2
INFO 82641 --- [           main] com.example.mysite.MysiteApplication     : No active profile set, falling back to default profiles: default
INFO 82641 --- [           main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data R2DBC repositories in DEFAULT mode.
INFO 82641 --- [           main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 5 ms. Found 0 R2DBC repository interfaces.
INFO 82641 --- [           main] o.s.b.web.embedded.netty.NettyWebServer  : Netty started on port 8080
INFO 82641 --- [           main] com.example.mysite.MysiteApplication     : Started MysiteApplication in 2.0 seconds (JVM running for 2.324)
INFO 82641 --- [           main] com.example.mysite.MysiteApplication     : --------------1-----------------
INFO 82641 --- [           main] com.example.mysite.MysiteApplication     : Question[id=1, question_text='What's new?', pub_date='2022-02-22T01:20:24.411035+09:00[Asia/Tokyo]']
INFO 82641 --- [           main] com.example.mysite.MysiteApplication     : --------------2-----------------
INFO 82641 --- [           main] com.example.mysite.MysiteApplication     : Question[id=1, question_text='What's new?', pub_date='2022-02-22T01:20:24.411035+09:00']
INFO 82641 --- [           main] com.example.mysite.MysiteApplication     : --------------3-----------------
```


## Adminページ

DjangoではCMSに該当するDjango Adminというモデル管理インタフェースを提供します。しかし、これは一般的な仕様ではないのでSpringでは自分で一からAdminページ構成しなければいけません。

[^1]: Initialize a Database Using Basic SQL Scripts, https://docs.spring.io/spring-boot/docs/current/reference/html/howto.html#howto.data-initialization.using-basic-sql-scripts
[^2]: R2DBC support, https://docs.spring.io/spring-data/r2dbc/docs/current/reference/html/#r2dbc.core