📘

Spring BootとReactの連携を学ぶためにToDoアプリを作る

2025/01/07に公開

はじめに

普段はサーバーサイドをJavaで実装しフレームワークはSpring Bootを使用しているが、個人開発のためにReactを学習したため、フロントエンドとサーバーサイドでどのように連携させるのか知りたいと思い調べながら実装してみることにした。

以下の記事を見つけたので、先日学習したてのDockerを使ってSpring Boot × React(TypeScript)で連携できる状態を目指して実装してみる。
https://qiita.com/studio_meowtoon/items/7d4d0bf73e04e01be558

今回は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の備忘録を見返して新しくリポジトリを作成する。
https://zenn.dev/yadonn/articles/ea3356426caccf

開発用リポジトリを作成

  1. ローカルリポジトリ作成からローカルリポジトリへの初回コミット
    開発用ワークスペースとしてpersonalDevelopmentディレクトリを作成し、その中に今回の対象であるtodoApp_testディレクトリを作成する。
    todoApp_testフォルダ配下でgit initをターミナル実行しローカルリポジトリを作成。
    とりあえずREADMEファイルだけ作成してステージにgit addし、ローカルリポジトリにgit commitする。
    ワークスペースの作成方法
  2. GitHub上でリモートリポジトリを新規作成
    GitHubのマイページでyour profileのリポジトリタブから、todoApp_testの名前でリポジトリを新規作成。
  3. ローカルリポジトリにリモートリポジトリを新規追加する
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を使用する手順

  1. 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
    

GUIクライアントから確認

https://www.mysql.com/jp/products/workbench/
上記の公式サイトからMySQL Workbenchをダウンロードする。
MySQLをGUIから操作する統合ビジュアルツールである。
このツールを活用することで手軽にテーブルのレコードを確認できる。

MySQL Workbenchでコネクションを作成したが、パスワードがわからずコネクションの中身を確認することができなかったが、MySQLインストール時のパスワードを入力することでコネクションサクセスできた。

また、MySQL Workbench上でSELECT * FROM db_todo.todos;を実行しても、Dockerコンテナ上で起動したMySQLデータベースサーバーからデータを取得できなかった。
理由は、MySQL Workbench上にスキーマのインポートがうまくできていなかったためなので、以下のサイトの該当箇所を参考に進めたところ、データベースサーバーからのデータ取得に成功した。
https://qiita.com/tamamu79/items/c045524f46060ef43f34#mysql-workbenchでsqlをimportする

REST APIサービスコンテナーを起動

REST APIサービスの実装手順

  1. プロジェクトの作成
    プロジェクトフォルダを作成する。
mkdir ~/todoApp_test/tmp/restapi-spring-boot
cd ~/todoApp_test/tmp/restapi-spring-boot
  1. Modelクラスの作成
    restapi-spring-bootディレクトリに移動した状態で以下のコマンドを実行する。
mkdir -p src/main/java/com/example/springboot/model
vim src/main/java/com/example/springboot/model/Todo.java

Todoエンティティクラスを作成する。

Todo.java
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
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();
}
  1. Application、Controller クラスの作成
    Applicationクラスを作成する。
vim src/main/java/com/example/springboot/Application.java
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
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);
    }
}
  1. Spring Boot の設定ファイルを追加
    application.propertiesファイルを作成する。
mkdir -p src/main/resources
vim src/main/resources/application.properties
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に設定されており、クエリのログは表示されない(自分のコードでは不要な行として削除)
  1. pom.xml の作成
    pom.xmlファイルを作成する。
vim pom.xml
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
      ビルドに関する設定を指定。
      プロジェクトのファイナル名や、ビルドプラグインの設定が含まれている。
  1. アプリの起動
    この後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をインストールするところから再度やり直すことにした。
https://sukkiri.jp/technologies/devtools/maven/apache-maven-installmacos.html
無事アプリの起動に成功。

以下に動作確認のテストを行うが、一旦ここまでで本プロジェクトで使うREST APIアプリの実装が完了。

アプリの動作確認(テスト)

別ターミナルを開き、curlコマンドで各ケースを確認する。

  1. 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
  }
]
  1. 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"
  }
]
  1. 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
}
  1. 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
}
  1. 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
}
  1. DELETE: /todos/{id} エンドポイントの動作確認
    IDでToDoアイテムを削除する。
curl -s -X DELETE http://localhost:5000/todos/9 | jq '.'
レスポンスなし

DockerでREST APIサービスを起動する手順

先ほどREST APIサービスをMavenでビルドしてテスト起動したが、今回のプロジェクトではDockerコンテナ上で起動させるため以下の手順で起動を実施する。

  1. コンテナーイメージの作成
    restapi-spring-bootディレクトリ直下でDockerfileを作成。
vim Dockerfile
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 .
  1. コンテナーを起動
    ローカルでコンテナを起動する。(※コンテナ停止は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
  1. コンテナーの動作確認
    別ターミナルから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
  1. コンテナーに接続
    別ターミナルからコンテナに接続。
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クライアントの作成手順

  1. プロジェクトの作成
    RESTクライアントのプロジェクトフォルダを作成。
cd ~/todoApp_test/tmp
mkdir restclt-reactts
cd restclt-reactts/
  1. TSXファイルの作成(TypeScript)
    index.tsxファイルを作成する。
mkdir -p src
vim src/index.tsx
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 />);
  1. HTMLファイルの作成
    index.htmlファイルを作成する。
vim src/index.html
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>
  1. SCSSファイルの作成
    styles.scssファイルを作成。
vim src/styles.scss
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;
}

ビルド

設定ファイルの作成

  1. webpackの設定ファイルを作成する。
vim webpack.config.ts
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)
    })
  ]
};
  1. package.jsonの作成
    プロジェクトの初期化を行う。
npm init -y

上記を実行すると、srcフォルダと同じ階層にpackage.jsonが生成される。
package.jsonを修正する。

vim package.json
package.json
{
  "name": "restclt-reactts",
  "version": "1.0.0",
  "description": "",
  "main": "webpack.config.ts",
  "scripts": {
    "build": "webpack --mode production"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
  1. ライブラリをインストールする。
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ディレクトリが作成される。

  1. TypeScriptのトランスパイル設定ファイルを作成する
vim tsconfig.json
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"
  ]
}

ビルド実行

  1. 環境変数を作成する
export API_URL=http://localhost:5000
  1. ビルドを実行する
npm run build
  1. アプリを起動する
    ※アプリを停止する時はctrl + Cを押下する。
python3 -m http.server 8000 --directory ./build

Webブラウザで確認する(※macOS)

open -a "Google Chrome" http://localhost:8000

コンテナの起動

  1. コンテナイメージの作成
    Nginxの設定ファイルを作成。
vim nginx.conf
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
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;"]
  1. コンテナイメージをビルド
    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にフロントエンドアプリのコンテナイメージをビルドすることができた。

  1. コンテナを起動
    ローカルでRESTクライアントのコンテナを起動する。(※コンテナの停止がctrl+Cを押下)
docker run --rm --publish 8000:80 --name app-local --net net-todo app-todo-reactts

ここまでの手順で、ローカル環境のDockerでフロントエンドアプリのコンテナを起動できた。

  1. コンテナの動作確認
    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
  1. コンテナに接続
    別ターミナルからコンテナに接続。
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)を作成することで、コンテナ間通信を実現できた。

  • 今後に向けて

  1. アプリの本番環境での起動に関して
    Dockerコンテナを活用することで、自身の開発環境で複数のアプリ(今回だとSpring Boot×React)を起動して通信連携できるようになったが、実際に自身が世の中にWebアプリをリリースする場合はどのようにしてるのかなどは自分はわかっていないので、今後学習していきたい。
  2. Dockerの基本や使い方を学んだが、Kubernetesの目的や使い方も気になっている。
  3. 今回はSpring Boot×React(TypeScript)でWebアプリを開発する見本をなぞるように進めたが、もう少しそれらを使った開発に慣れたいので、別途また自分でも開発をしてみる。

参考にしたサイト

https://qiita.com/studio_meowtoon/items/7d4d0bf73e04e01be558
https://qiita.com/studio_meowtoon/items/ce1c853893388c106d22

Discussion