Spring BootとReactの連携を学ぶためにToDoアプリを作る
はじめに
普段はサーバーサイドをJavaで実装しフレームワークはSpring Bootを使用しているが、個人開発のためにReactを学習したため、フロントエンドとサーバーサイドでどのように連携させるのか知りたいと思い調べながら実装してみることにした。
以下の記事を見つけたので、先日学習したてのDockerを使ってSpring Boot × React(TypeScript)で連携できる状態を目指して実装してみる。
今回はJavaやTypeScriptのコードを細かく説明するのではなく、Docker環境上でデータベースを起動したり、別コンテナで起動したAPIと先ほどのデータベースを連携させたり、SpringBootとReactを連携させたり、Dockerコンテナ間の通信連携という部分を中心に学習していきたい。
開発環境
- OS
- macOS Sonoma バージョン14.5
- IDE
- VSCode
- 言語
- Java(Spring Boot)
- TypeScript(React)
- DB
- MySQL
- 環境構築
- Docker
実現すること
前述の@studio_meowtoonさんの記事と基本的に同じです。
学習のためにローカルで再現することが目的なので記事通りの手順で進めますが、自分の実行環境でうまくいかなかったことや疑問が生じたところは適宜自力で調べて解決しました。
ツールのバージョンチェック
npm
$ node -v
v20.18.0
$ npm -v
10.9.0
Docker
$ docker --version
Docker version 27.4.1, build b9d17eaebb
バージョン管理(Git)について
以前自分で書いたGitの備忘録を見返して新しくリポジトリを作成する。
開発用リポジトリを作成
- ローカルリポジトリ作成からローカルリポジトリへの初回コミット
開発用ワークスペースとしてpersonalDevelopmentディレクトリを作成し、その中に今回の対象であるtodoApp_testディレクトリを作成する。
todoApp_testフォルダ配下でgit init
をターミナル実行しローカルリポジトリを作成。
とりあえずREADMEファイルだけ作成してステージにgit add
し、ローカルリポジトリにgit commit
する。
※ワークスペースの作成方法 - GitHub上でリモートリポジトリを新規作成
GitHubのマイページでyour profileのリポジトリタブから、todoApp_testの名前でリポジトリを新規作成。 - ローカルリポジトリにリモートリポジトリを新規追加する
git remote add origin https://github.com/kounosukeshibata/todoApp_test.git
上記のコマンドにより、次回からorigin指定でgit pushできる。
4. ローカルリポジトリのコミット履歴をリモートリポジトリに送信
git push origin master
サーバーサイド
RDBMSデータベースとToDoアプリ用のREST APIサービスをDockerコンテナとしてそれぞれ起動する手順を記す。
また、今回はDockerfile作成の手順を通じてコンテナの起動を行う。
MySQLデータベースコンテナーの起動
Dockerネットワークを作成する
$ docker network create net-todo
Docker内でコンテナ間通信を可能にするためのユーザ定義ネットワーク(net-todoと名付ける)を作成する。
Docker環境でMySQLを使用する手順
- Dockerfileを作成する手順
- MySQLコンテナ実行用のフォルダを作成・移動する
mkdir -p ~/todoApp_test/tmp/mysql-base cd ~/todoApp_test/tmp/mysql-base
- SQLファイルを作成
vim init_db.sql
- SQLファイルの内容init_db
DROP DATABASE IF EXISTS db_todo; CREATE DATABASE db_todo; -- CREATE TABLEステートメントでデータベースを指定 CREATE TABLE db_todo.todos ( id INT AUTO_INCREMENT PRIMARY KEY, content TEXT NOT NULL, created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, completed_date TIMESTAMP ); INSERT INTO db_todo.todos (content, completed_date) VALUES ('食材を買う', NOW()), ('報告書を仕上げる', NOW()), ('運動する', NOW()), ('本を読む', NULL), ('請求書を支払う', NULL), ('家を掃除する', NULL), ('プレゼンの準備する', NULL), ('コードを書く', NULL);
- Dockerファイルを作成
vim Dockerfile
- Dockerファイルの内容
# set up the container. FROM mysql:8.0 # set utf-8 encoding and mysql root password. ENV LANG=ja_JP.UTF-8 ENV MYSQL_ROOT_PASSWORD=password # copy the init sql file to the init dir. COPY init_db.sql /docker-entrypoint-initdb.d/
- コンテナイメージをビルドする
docker build --no-cache --tag mysql-base:latest .
- コンテナイメージを確認する
docker images | grep mysql-base
- コンテナーを起動する
docker run -d --publish 3306:3306 --name mysql-todo --net net-todo --volume db_todo_mysql:/var/lib/mysql mysql-base
- MySQLコンテナ実行用のフォルダを作成・移動する
GUIクライアントから確認
MySQLをGUIから操作する統合ビジュアルツールである。
このツールを活用することで手軽にテーブルのレコードを確認できる。
MySQL Workbenchでコネクションを作成したが、パスワードがわからずコネクションの中身を確認することができなかったが、MySQLインストール時のパスワードを入力することでコネクションサクセスできた。
また、MySQL Workbench上でSELECT * FROM db_todo.todos;
を実行しても、Dockerコンテナ上で起動したMySQLデータベースサーバーからデータを取得できなかった。
理由は、MySQL Workbench上にスキーマのインポートがうまくできていなかったためなので、以下のサイトの該当箇所を参考に進めたところ、データベースサーバーからのデータ取得に成功した。
REST APIサービスコンテナーを起動
REST APIサービスの実装手順
- プロジェクトの作成
プロジェクトフォルダを作成する。
mkdir ~/todoApp_test/tmp/restapi-spring-boot
cd ~/todoApp_test/tmp/restapi-spring-boot
- Modelクラスの作成
restapi-spring-bootディレクトリに移動した状態で以下のコマンドを実行する。
mkdir -p src/main/java/com/example/springboot/model
vim src/main/java/com/example/springboot/model/Todo.java
Todoエンティティクラスを作成する。
package com.example.springboot.model;
import java.util.Date;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
import javax.persistence.Transient;
import lombok.Data;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
// ToDo エンティティを表すクラス
@Data
@Entity @Table(name="todos")
public class Todo {
// RDBMS 本来の int 型のキー:API 入出力の JSON にはマッピングしない
@JsonIgnore
@GeneratedValue(strategy=GenerationType.IDENTITY)
@Id @Column(name="id", unique=true) Long _rdbms_id;
// string 型の ID を定義:RDBMS 側にはマッピングしない
@Transient String id;
public String getId() { return Long.toString(_rdbms_id); }
public void setId(String id) { _rdbms_id = Long.parseLong(id); }
@JsonProperty("content")
@Column(name="content") String content;
@JsonProperty("created_date")
@Column(name="created_date")
@Temporal(TemporalType.TIMESTAMP) Date createdDate;
@JsonProperty("completed_date")
@Column(name="completed_date")
@Temporal(TemporalType.TIMESTAMP) Date completedDate;
}
TodoRepositoryインタフェースを作成する。
mkdir -p src/main/java/com/example/springboot/model
vim src/main/java/com/example/springboot/model/TodoRepository.java
package com.example.springboot.model;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
// ToDo エンティティに対する Repository インタフェース
public interface TodoRepository extends JpaRepository<Todo, Long> {
List<Todo> findByCompletedDateIsNotNull();
}
- Application、Controller クラスの作成
Applicationクラスを作成する。
vim src/main/java/com/example/springboot/Application.java
package com.example.springboot;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableAsync;
@EntityScan("com.example.springboot.*")
@EnableJpaRepositories("com.example.springboot.*")
@SpringBootApplication
@EnableAsync
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
TodoControllerクラスを作成する。
vim src/main/java/com/example/springboot/TodoController.java
package com.example.springboot;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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.RestController;
import com.example.springboot.model.Todo;
import com.example.springboot.model.TodoRepository;
@CrossOrigin( // CORS 設定:適切に修正してください。
origins = "*",
methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE }
)
@RestController
@RequestMapping("/todos")
public class TodoController {
private final TodoRepository _todoRepository;
public TodoController(TodoRepository todoRepository) {
_todoRepository = todoRepository;
}
// すべての ToDo アイテムを取得します。
@GetMapping
@Async public CompletableFuture<List<Todo>> getAllTodos() {
List<Todo> todos = _todoRepository.findAll();
return CompletableFuture.completedFuture(todos);
}
// 完了した ToDo アイテムを取得します。
@GetMapping("/complete")
@Async public CompletableFuture<List<Todo>> getCompleteTodos() {
List<Todo> todos = _todoRepository.findByCompletedDateIsNotNull();
return CompletableFuture.completedFuture(todos);
}
// ID で ToDo アイテムを取得します。
@GetMapping("/{id}")
@Async public CompletableFuture<Optional<Todo>> getTodo(@PathVariable String id) {
Optional<Todo> todo = _todoRepository.findById(Long.parseLong(id));
return CompletableFuture.completedFuture(todo);
}
// 新しい ToDo アイテムを追加します。
@PostMapping
@Async public CompletableFuture<Todo> createTodo(@RequestBody Todo todo) {
todo.setCreatedDate(new Date());
return CompletableFuture.completedFuture(_todoRepository.save(todo));
}
// 既存の ToDo アイテムを更新します。
@PutMapping("/{id}")
@Async public CompletableFuture<Todo> updateTodo(@PathVariable String id, @RequestBody Todo todo) {
Optional<Todo> exist = _todoRepository.findById(Long.parseLong(id));
if (exist.isPresent()) {
Todo target = exist.get();
target.setContent(todo.getContent());
target.setCompletedDate(todo.getCompletedDate());
return CompletableFuture.completedFuture(_todoRepository.save(target));
}
return CompletableFuture.completedFuture(null);
}
// ID で ToDo アイテムを削除します。
@DeleteMapping("/{id}")
@Async public CompletableFuture<Void> deleteTodo(@PathVariable String id) {
_todoRepository.deleteById(Long.parseLong(id));
return CompletableFuture.completedFuture(null);
}
}
- Spring Boot の設定ファイルを追加
application.propertiesファイルを作成する。
mkdir -p src/main/resources
vim src/main/resources/application.properties
spring.datasource.url=jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
spring.jpa.show-sql=false
- application.propertiesのコード内容の説明
- spring.datasource.url
DB接続のURLを指定する。
${}という書き方はプレースホルダーといい、これを指定することで実際の値が環境変数より取得される。実行者は、実行時に環境変数を指定する必要がある。 - spring.datasource.username
DB接続に使用するユーザー名を指定する。 - spring.datasource.password
DB接続に使用するパスワードを指定する。 - spring.jpa.show-sql
JPAによるクエリのログを表示するかどうかを指定する。ここではfalseに設定されており、クエリのログは表示されない(自分のコードでは不要な行として削除)
- spring.datasource.url
- pom.xml の作成
pom.xmlファイルを作成する。
vim pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.15</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>restapi-spring-boot</artifactId>
<version>1.0</version>
<name>restapi-spring-boot</name>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>javax.persistence-api</artifactId>
<version>2.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.22</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- pom.xmlのコード内容の説明
- modelVersion
Mavenモデルのバージョンを指定。通常は4.0.0を指定。 - parent
本プロジェクトの親プロジェクトを指定。
spring-boot-starter-parentは、Spring Bootプロジェクトの親プロジェクト。バージョン情報やビルド設定が事前に設定されている。 - groupId, artifactId, version, name
プロジェクトのMavenのグループID、アーティファクトID、バージョン、プロジェクト名を指定。 - properties
プロジェクト全体で使用されるプロパティを指定。
例えば、javaバージョンやプロジェクトのエンコーディングなど。 - dependencies
プロジェクトが依存するライブラリを指定。 - build
ビルドに関する設定を指定。
プロジェクトのファイナル名や、ビルドプラグインの設定が含まれている。
- modelVersion
- アプリの起動
この後Dockerコンテナ上で起動するが、ここでは実装後のテスト起動を行う。
先ほど起動したMySQLデータベースコンテナーの情報に関して、環境変数を作成してexportしようとしたが何故かうまくいかなかった。
代替として、以下に該当する値をapplication.propertiesに直接書き込んで保存した。
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=db_todo
DB_USER=root
DB_PASSWORD=*********
上記のデータベーススキーマ情報を保存後、Spring Bootアプリを起動する。
mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=5000"
ここで、mvnコマンドを実行したところmvn: command not found
というエラーが出た。
mvnコマンドが使えない状況だったため、Apache Mavenをインストールするところから再度やり直すことにした。
無事アプリの起動に成功。
以下に動作確認のテストを行うが、一旦ここまでで本プロジェクトで使うREST APIアプリの実装が完了。
アプリの動作確認(テスト)
別ターミナルを開き、curlコマンドで各ケースを確認する。
- GET: /todos エンドポイントの動作確認
全てのToDoアイテムを取得する。
curl -s http://localhost:5000/todos | jq '.'
[
{
"id": "1",
"content": "食材を買う",
"created_date": "2025-01-06T12:23:08.000+00:00",
"completed_date": "2025-01-06T12:23:08.000+00:00"
},
{
"id": "2",
"content": "報告書を仕上げる",
"created_date": "2025-01-06T12:23:08.000+00:00",
"completed_date": "2025-01-06T12:23:08.000+00:00"
},
{
"id": "3",
"content": "運動する",
"created_date": "2025-01-06T12:23:08.000+00:00",
"completed_date": "2025-01-06T12:23:08.000+00:00"
},
{
"id": "4",
"content": "本を読む",
"created_date": "2025-01-06T12:23:08.000+00:00",
"completed_date": null
},
{
"id": "5",
"content": "請求書を支払う",
"created_date": "2025-01-06T12:23:08.000+00:00",
"completed_date": null
},
{
"id": "6",
"content": "家を掃除する",
"created_date": "2025-01-06T12:23:08.000+00:00",
"completed_date": null
},
{
"id": "7",
"content": "プレゼンの準備する",
"created_date": "2025-01-06T12:23:08.000+00:00",
"completed_date": null
},
{
"id": "8",
"content": "コードを書く",
"created_date": "2025-01-06T12:23:08.000+00:00",
"completed_date": null
}
]
- GET: /todos/complete エンドポイントの動作確認
完了したToDoアイテムを取得する。
curl -s http://localhost:5000/todos/complete | jq '.'
[
{
"id": "1",
"content": "食材を買う",
"created_date": "2025-01-06T12:23:08.000+00:00",
"completed_date": "2025-01-06T12:23:08.000+00:00"
},
{
"id": "2",
"content": "報告書を仕上げる",
"created_date": "2025-01-06T12:23:08.000+00:00",
"completed_date": "2025-01-06T12:23:08.000+00:00"
},
{
"id": "3",
"content": "運動する",
"created_date": "2025-01-06T12:23:08.000+00:00",
"completed_date": "2025-01-06T12:23:08.000+00:00"
}
]
- GET: /todos/{id} エンドポイントの動作確認
IDでToDoアイテムを取得する。
curl -s http://localhost:5000/todos/8 | jq '.'
{
"id": "8",
"content": "コードを書く",
"created_date": "2025-01-06T12:23:08.000+00:00",
"completed_date": null
}
- POST: /todos エンドポイントの動作確認
新しいToDoアイテムを追加する。
curl -s -X POST http://localhost:5000/todos \
-H 'Content-Type: application/json; charset=utf-8' \
-d \
'{
"content": "昼寝をする"
}' | jq '.'
{
"id": "9",
"content": "昼寝をする",
"created_date": "2025-01-06T14:17:25.490+00:00",
"completed_date": null
}
- PUT: /todos/{id} エンドポイントの動作確認
既存のToDoアイテムを更新する。
curl -s -X PUT http://localhost:5000/todos/9 \
-H 'Content-Type: application/json; charset=utf-8' \
-d \
'{
"content": "窓を開ける"
}' | jq '.'
{
"id": "9",
"content": "窓を開ける",
"created_date": "2025-01-06T14:17:25.000+00:00",
"completed_date": null
}
- DELETE: /todos/{id} エンドポイントの動作確認
IDでToDoアイテムを削除する。
curl -s -X DELETE http://localhost:5000/todos/9 | jq '.'
レスポンスなし
DockerでREST APIサービスを起動する手順
先ほどREST APIサービスをMavenでビルドしてテスト起動したが、今回のプロジェクトではDockerコンテナ上で起動させるため以下の手順で起動を実施する。
- コンテナーイメージの作成
restapi-spring-bootディレクトリ直下でDockerfileを作成。
vim Dockerfile
# build the app.
FROM openjdk:17-jdk-slim as build-env
# set the working dir.
WORKDIR /app
# install build tools and libraries.
RUN apt-get update && apt-get install -y maven
# copy source code to the working dir.
COPY . .
# build the app.
RUN mvn clean package
# set up the container.
FROM debian:12-slim
# set the working dir.
WORKDIR /app
# install the openjdk-17-jre-headless and clean up unnecessary files.
RUN apt-get update && \
apt-get install -y --no-install-recommends openjdk-17-jre-headless && \
apt-get autoremove -y && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# set environment variables.
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64/jre
ENV PATH=$PATH:$JAVA_HOME/bin
# copy the built app from the build-env.
COPY --from=build-env /app/target/*.jar app.jar
# expose the port.
EXPOSE 8080
# command to run the app using java.
ENTRYPOINT ["java","-jar","app.jar"]
Dockerデーモンを起動する。
sudo service docker start
- Dockerデーモン
dockerコマンドは、コマンド自体(client)とそれを受け取るDockerデーモン(server)という構成になっていて、runやbuildというコマンドを受け取ったDockerデーモンが実際の処理を行っている。イメージ、コンテナ等のDockerオブジェクトの作成・管理を行う。
https://docs.docker.jp/v1.10/engine/understanding-docker.html#id7 - Docker Engineとの違い
https://docs.docker.jp/v1.10/machine/overview.html#docker-engine-docker-machine
Docker デーモンはクライアント・サーバ型アプリケーションです。デーモンは特定の REST API インターフェースとコマンド・ライン・インターフェース(CLI)でデーモンと通信する。一方で、Docker エンジンは CLI からの docker コマンドを受け付ける。 - Docker Machineとは
仮想マシン上にDocker Engineをインストールするツールのこと。
コンテナイメージをビルドする。
docker build --no-cache --tag api-todo-spring-boot:latest .
- コンテナーを起動
ローカルでコンテナを起動する。(※コンテナ停止はCtrl + Cを押下する)
docker run --rm --publish 5000:8080 --name api-local --net net-todo --env DB_HOST=mysql-todo --env DB_PORT=3306 --env DB_NAME=db_todo --env DB_USER=root --env DB_PASSWORD=password api-todo-spring-boot
- コンテナーの動作確認
別ターミナルからcurlコマンドで確認。
IDでToDoアイテムを取得する。
curl -s http://localhost:5000/todos/8 | jq '.'
レスポンス
{
"id": "8",
"content": "コードを書く",
"created_date": "2025-01-03T18:47:22.000+00:00",
"completed_date": null
}
コンテナの状態を確認してみる。
docker ps
レスポンス
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b5ffd30a6969 api-todo-spring-boot "java -jar app.jar" 12 hours ago Up 12 hours 0.0.0.0:5000->8080/tcp api-local
97f60563f43a mysql-base "docker-entrypoint.s…" 12 hours ago Up 12 hours 0.0.0.0:3306->3306/tcp, 33060/tcp mysql-todo
- コンテナーに接続
別ターミナルからコンテナに接続。
docker exec -it api-local /bin/bash
コンテナ接続後にディレクトリを確認。
pwd
レスポンス
/app
ls -lah
レスポンス
total 40M
drwxr-xr-x 1 root root 4.0K Jan 6 16:24 .
drwxr-xr-x 1 root root 4.0K Jan 6 16:24 ..
-rw-r--r-- 1 root root 40M Jan 6 16:24 app.jar
topコマンドで状況を確認。
apt update
apt install procps
top
レスポンス
top - 04:02:53 up 2 days, 21:05, 0 user, load average: 1.87, 2.09, 2.07
Tasks: 3 total, 1 running, 2 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.1 us, 0.1 sy, 0.0 ni, 99.7 id, 0.0 wa, 0.0 hi, 0.1 si, 0.0 st
MiB Mem : 7837.5 total, 187.5 free, 1504.0 used, 6354.7 buff/cache
MiB Swap: 1024.0 total, 1024.0 free, 0.0 used. 6333.5 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1 root 20 0 6706504 338380 22516 S 0.7 4.2 1:20.24 java
605 root 20 0 4052 3276 2764 S 0.0 0.0 0:00.01 bash
705 root 20 0 8576 4432 2384 R 0.0 0.1 0:00.00 top
コンテナの情報を表示。
cat /etc/*-release
レスポンス
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
フロントエンド
RESTクライアントの作成手順
- プロジェクトの作成
RESTクライアントのプロジェクトフォルダを作成。
cd ~/todoApp_test/tmp
mkdir restclt-reactts
cd restclt-reactts/
- TSXファイルの作成(TypeScript)
index.tsxファイルを作成する。
mkdir -p src
vim src/index.tsx
import './styles.scss';
import React, { useState, useEffect, useRef } from 'react';
import ReactDOM from "react-dom/client"; // 'react-dom/client'からインポート
// ToDo アイテムのインターフェイス
interface Todo {
id: string;
content: string;
created_date: string | null;
completed_date: string | null;
editing: boolean;
editContent: string;
}
// メインコンポーネント
function App() {
const _apiUrl: string | undefined = process.env.API_URL; // API の URL
const [_todos, setTodos]: [Todo[], React.Dispatch<React.SetStateAction<Todo[]>>] = useState<Todo[]>([]); // ToDo の一覧を管理するステート変数
const _inputAdd: React.MutableRefObject<HTMLInputElement | null> = useRef<HTMLInputElement | null>(null); // 新規追加の input 要素の参照
// コンポーネントがマウントされたときに実行される副作用フック
useEffect(() => {
fetchAndRender(); // データの取得と表示
}, []);
// データの取得と表示
const fetchAndRender = async (): Promise<void> => {
try {
const response: Response = await fetch(`${_apiUrl}/todos`);
if (!response.ok) { alert(`Fetch failed with status: ${response.status}`); return; }
const data: Array<Todo> = await response.json();
const filtered: Array<Todo> = data.filter((elem: Todo) => elem.completed_date === null);
const updated: Array<Todo> = filtered.map((elem: Todo) => ({ // ToDo データに編集関連の情報を追加して新しい配列を作成
...elem, // 全ての要素を引継ぎ
editing: false, // 編集フラグは OFF
editContent: elem.content, // 元のテキストをコピー
}));
setTodos(updated); // 新しい配列をセットして ToDo データを更新
} catch (error) { alert(`Error fetching todos: ${error}`); }
};
// 新規追加ボタンのクリックイベントハンドラ
const handleButtonAddClick = async (): Promise<void> => {
try {
if (_inputAdd.current === null) { return; }
const content: string = _inputAdd.current.value.trim();
if (content === "") { return; }
if (confirm("アイテムを新規追加しますか?")) {
const response: Response = await fetch(`${_apiUrl}/todos`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: content })
});
if (!response.ok) { alert(`Fetch failed with status: ${response.status}`); }
fetchAndRender();
_inputAdd.current.value = "";
}
} catch (error) { alert(`Error adding todos: ${error}`); }
};
// 完了ボタンのクリックイベントハンドラ
const handleButtonCompleteClick = (todo: Todo) => async (): Promise<void> => {
try {
if (confirm("このアイテムを完了にしますか?")) {
const response: Response = await fetch(`${_apiUrl}/todos/${todo.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: todo.content,
completed_date: new Date().toISOString()
})
});
if (!response.ok) { alert(`Update failed with status: ${response.status}`); }
fetchAndRender();
}
} catch (error) { alert(`Error updating todos: ${error}`); }
};
// テキスト要素のクリックイベントハンドラ
const handleContentClick = (todo: Todo) => (): void => {
setTodos((state: Todo[]) => state.map((elem: Todo) =>
({ ...elem, editing: false }) // すべて編集フラグは OFF
));
setTodos((state: Todo[]) => state.map((elem: Todo) =>
elem.id === todo.id
? { ...elem, editing: true } // id が一致したら編集フラグは ON
: elem // 一致しなければそのまま
));
};
// テーブル行テキストのチェンジイベントハンドラ
const handleRowContentChange = (todo: Todo, value: string) => {
setTodos((state: Todo[]) => state.map((elem: Todo) =>
elem.id === todo.id
? { ...elem, editContent: value } // id が一致したら編集テキストに適用
: elem // 一致しなければそのまま
));
};
// 更新ボタンのクリックイベントハンドラ
const handleButtonUpdateClick = (todo: Todo) => async (): Promise<void> => {
try {
const updatedContent: string = todo.editContent.trim();
if (updatedContent === "") { return; }
if (confirm("このアイテムを更新しますか?")) {
const response: Response = await fetch(`${_apiUrl}/todos/${todo.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: updatedContent })
});
if (!response.ok) { alert(`Fetch failed with status: ${response.status}`); }
fetchAndRender();
}
} catch (error) { alert(`Error updating todos: ${error}`); }
};
// 削除ボタンのクリックイベントハンドラ
const handleButtonDeleteClick = (todo: Todo) => async (): Promise<void> => {
try {
if (confirm("このアイテムを削除しますか?")) {
const response: Response = await fetch(`${_apiUrl}/todos/${todo.id}`, {
method: "DELETE",
});
if (!response.ok) { alert(`Delete failed with status: ${response.status}`); }
fetchAndRender();
}
} catch (error) { alert(`Error deleting todos: ${error}`); }
};
// JSX レンダリング部分
return (
<div className="div-container">
<h1>ToDo リスト</h1>
<div className="div-add">
<input
type="text" placeholder="新規アイテムを追加"
ref={_inputAdd}
onClick={handleContentClick({ id: '_dummy', content: '', created_date: null, completed_date: null, editing: false, editContent: '' })}
/>
<button id="button-add" onClick={handleButtonAddClick}>
新規追加
</button>
</div>
<div className="div-table" id="div-todo-list">
{_todos.map((todo) => (
<div key={todo.id} className="div-row">
<div className="div-content">
{/* 編集中の場合は input を表示、そうでなければ div を表示 */}
{todo.editing ? (
<input
type="text"
className="input-edit"
value={todo.editContent}
onChange={(e) => handleRowContentChange(todo, e.target.value)}
/>
) : (
<div onClick={handleContentClick(todo)}>
{todo.content}
</div>
)}
</div>
<div className="div-buttons">
<button
className="button-complete"
onClick={handleButtonCompleteClick(todo)}
>
完了
</button>
<button
className="button-update"
onClick={handleButtonUpdateClick(todo)}
disabled={!todo.editing}
>
更新
</button>
<button
className="button-delete"
onClick={handleButtonDeleteClick(todo)}
>
削除
</button>
</div>
</div>
))}
</div>
</div>
);
}
// アプリケーションをレンダリング
const root = ReactDOM.createRoot(document.getElementById("app")!); // createRootを使う
root.render(<App />);
- HTMLファイルの作成
index.htmlファイルを作成する。
vim src/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ToDo アプリ</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
- SCSSファイルの作成
styles.scssファイルを作成。
vim src/styles.scss
$primary-font: Arial, sans-serif;
/* カラーパレット */
$blue: royalblue;
$green: mediumseagreen;
$yellow: lightyellow;
$red: indianred;
$gray: gray;
body {
font-family: $primary-font;
margin: 0;
padding: 0;
}
h1 {
text-align: center;
margin: 20px 0;
}
/* コンテナ */
.div-container {
max-width: 800px;
margin: 0 auto;
}
/* テーブル */
.div-table {
display: table;
width: 100%;
max-width: 800px;
margin: 0 auto;
border-collapse: collapse;
border: 1px solid #ddd;
}
/* テーブルの行 */
.div-row {
display: table-row;
}
/* 行のテキストセル */
.div-content {
display: table-cell;
padding: 10px;
border-top: 1px solid #ddd;
flex: 1;
text-align: left;
width: 68%;
}
/* ボタングループセル */
.div-buttons {
display: table-cell;
padding: 10px;
border-top: 1px solid #ddd;
text-align: right;
}
/* 完了ボタン */
.button-complete {
margin-left: 10px;
padding: 8px 12px;
background-color: $blue;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
@media (max-width: 768px) {
margin-left: 2px;
padding: 5px 2px;
}
}
/* 更新ボタン */
.button-update {
margin-left: 10px;
padding: 8px 12px;
background-color: $green;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
&:disabled {
background-color: $gray;
cursor: not-allowed;
}
@media (max-width: 768px) {
margin-left: 2px;
padding: 5px 2px;
}
}
/* 更新テキスト */
.input-edit {
padding: 8px;
background-color: $yellow;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 4px;
width: 100%;
}
/* 削除ボタン */
.button-delete {
margin-left: 10px;
padding: 8px 12px;
background-color: $red;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
@media (max-width: 768px) {
margin-left: 2px;
padding: 5px 2px;
}
}
/* 新規追加エリア */
.div-add {
margin-bottom: 20px;
display: flex;
align-items: center;
width: 100%;
}
/* 新規追加テキスト */
.div-add input {
flex: 1;
padding: 8px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 4px;
&:focus {
background-color: $yellow;
}
}
/* 新規追加ボタン */
.div-add button {
margin-left: 10px;
padding: 8px 12px;
background-color: $blue;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
ビルド
設定ファイルの作成
- webpackの設定ファイルを作成する。
vim webpack.config.ts
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
module.exports = {
mode: 'production',
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'build'),
filename: 'bundle.js',
},
module: {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: 'ts-loader'
},
{
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'sass-loader']
}
]
},
resolve: {
extensions: ['.ts', '.tsx', '.js']
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html'
}),
new webpack.DefinePlugin({
'process.env.API_URL': JSON.stringify(process.env.API_URL)
})
]
};
- package.jsonの作成
プロジェクトの初期化を行う。
npm init -y
上記を実行すると、srcフォルダと同じ階層にpackage.jsonが生成される。
package.jsonを修正する。
vim package.json
{
"name": "restclt-reactts",
"version": "1.0.0",
"description": "",
"main": "webpack.config.ts",
"scripts": {
"build": "webpack --mode production"
},
"keywords": [],
"author": "",
"license": "ISC"
}
- ライブラリをインストールする。
npm install react react-dom @types/react @types/react-dom webpack webpack-cli html-webpack-plugin css-loader style-loader node-sass sass-loader typescript ts-node ts-loader --save-dev
上記を実行すると、srcフォルダと同じ階層に、node_modulesディレクトリとpackage-lock.jsonディレクトリが作成される。
- TypeScriptのトランスパイル設定ファイルを作成する
vim tsconfig.json
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"jsx": "react"
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"node_modules"
]
}
ビルド実行
- 環境変数を作成する
export API_URL=http://localhost:5000
- ビルドを実行する
npm run build
- アプリを起動する
※アプリを停止する時はctrl + Cを押下する。
python3 -m http.server 8000 --directory ./build
Webブラウザで確認する(※macOS)
open -a "Google Chrome" http://localhost:8000
コンテナの起動
- コンテナイメージの作成
Nginxの設定ファイルを作成。
vim nginx.conf
user nginx;
worker_processes 3; # ワーカープロセス数を 3 に設定:調整してください。
events {
worker_connections 256; # 同時接続数の最大値を設定:調整してください。
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html; # 静的ファイルのルートディレクトリ
index index.html; # デフォルトのインデックスファイル
}
}
}
Dockerファイルを作成。
vim Dockerfile
# build the app.
FROM node:lts-bookworm-slim as build-env
# Pythonとビルドツールをインストール
RUN apt-get update && apt-get install -y python3 make g++
# set the working dir.
WORKDIR /app
# copy json files to the working dir.
COPY package.json package-lock.json tsconfig.json /app/
COPY webpack.config.ts /app/
# copy the source files to the working dir.
COPY src /app/src
# set the environment.
ARG API_URL
ENV API_URL=$API_URL
# run the build command to build the app.
RUN npm install
RUN npm run build
# set up the production container.
FROM nginx:bookworm
# copy nginx.conf.
COPY nginx.conf /etc/nginx/nginx.conf
# copy the build output from the build-env.
COPY --from=build-env /app/build /usr/share/nginx/html
# expose port.
EXPOSE 80
# command to run nginx.
CMD ["nginx","-g","daemon off;"]
- コンテナイメージをビルド
Dockerデーモンを起動。
sudo service docker start
コンテナイメージをビルドする。
docker build --no-cache --build-arg API_URL=http://localhost:5000 --tag app-todo-reactts:latest .
コンテナイメージを確認
docker images | grep app-todo-reactts
レスポンス
app-todo-reactts latest 8b398433ef7d 41 seconds ago 280MB
ここまでの手順で、ローカル環境のDockerにフロントエンドアプリのコンテナイメージをビルドすることができた。
- コンテナを起動
ローカルでRESTクライアントのコンテナを起動する。(※コンテナの停止がctrl+Cを押下)
docker run --rm --publish 8000:80 --name app-local --net net-todo app-todo-reactts
ここまでの手順で、ローカル環境のDockerでフロントエンドアプリのコンテナを起動できた。
- コンテナの動作確認
Webブラウザで確認する(※macOS)
open -a "Google Chrome" http://localhost:8000
コンテナの状態を確認してみる。
docker ps
レスポンス
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
e69f2243697a app-todo-reactts "/docker-entrypoint.…" 2 minutes ago Up 2 minutes 0.0.0.0:8000->80/tcp app-local
4a0cdd615261 api-todo-spring-boot "java -jar app.jar" 8 hours ago Up 8 hours 0.0.0.0:5000->8080/tcp api-local
97f60563f43a mysql-base "docker-entrypoint.s…" 21 hours ago Up 21 hours 0.0.0.0:3306->3306/tcp, 33060/tcp mysql-todo
- コンテナに接続
別ターミナルからコンテナに接続。
docker exec -it app-local /bin/bash
コンテナ接続後にディレクトリを確認。(※コンテナから出る時はctrl+D)
pwd
レスポンス
/
cd /usr/share/nginx/html
ls -lah
レスポンス
total 208K
drwxr-xr-x 1 root root 4.0K Jan 7 12:35 .
drwxr-xr-x 1 root root 4.0K Dec 24 23:02 ..
-rw-r--r-- 1 root root 497 Nov 26 15:55 50x.html
-rw-r--r-- 1 root root 186K Jan 7 12:35 bundle.js
-rw-r--r-- 1 root root 962 Jan 7 12:35 bundle.js.LICENSE.txt
-rw-r--r-- 1 root root 239 Jan 7 12:35 index.html
topコマンドで状況を確認。
apt update
apt install procps
top
レスポンス
top - 12:54:09 up 3 days, 5:27, 0 user, load average: 2.19, 2.26, 2.18
Tasks: 6 total, 1 running, 5 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.0 us, 0.1 sy, 0.0 ni, 99.8 id, 0.0 wa, 0.0 hi, 0.1 si, 0.0 st
MiB Mem : 7837.5 total, 800.1 free, 1534.4 used, 5711.9 buff/cache
MiB Swap: 1024.0 total, 1024.0 free, 0.0 used. 6303.1 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1 root 20 0 11160 7200 6048 S 0.0 0.1 0:00.01 nginx
29 nginx 20 0 11332 2732 1540 S 0.0 0.0 0:00.00 nginx
30 nginx 20 0 11332 2732 1540 S 0.0 0.0 0:00.00 nginx
31 nginx 20 0 11332 2600 1408 S 0.0 0.0 0:00.00 nginx
38 root 20 0 4052 3276 2764 S 0.0 0.0 0:00.01 bash
228 root 20 0 8748 4376 2328 R 0.0 0.1 0:00.00 top
コンテナの情報を表示。
cat /etc/*-release
レスポンス
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
まとめ
-
今回学びたかった内容について
プロジェクトのトップディレクトリから下の階層に、データベース、REST APIサービス(SS)、RESTクライアント(FE)のそれぞれのサブディレクトリを作成し、各サブディレクトリの直下にDockerfileを作成した。
各サブディレクトリごとにターミナル画面を変えてコンテナを起動することで、それぞれのアプリをDockerコンテナとして起動した。
さらに、Dockerネットワーク(今回はnet-todo)を作成することで、コンテナ間通信を実現できた。 -
今後に向けて
- アプリの本番環境での起動に関して
Dockerコンテナを活用することで、自身の開発環境で複数のアプリ(今回だとSpring Boot×React)を起動して通信連携できるようになったが、実際に自身が世の中にWebアプリをリリースする場合はどのようにしてるのかなどは自分はわかっていないので、今後学習していきたい。 - Dockerの基本や使い方を学んだが、Kubernetesの目的や使い方も気になっている。
- 今回はSpring Boot×React(TypeScript)でWebアプリを開発する見本をなぞるように進めたが、もう少しそれらを使った開発に慣れたいので、別途また自分でも開発をしてみる。
参考にしたサイト
Discussion