プログラミング

Spring BootとReactだけの簡単メモアプリ

この記事は約22分で読めます。

こんにちは。じゅんです。

今回はDBを伴わない簡単なメモアプリを作成してみたいと思います。

Spring BootとReactを連携させる方法を習得することを目的としています。

学習記録も兼ねた記事一覧をこちらの記事にまとめています。

事前準備

各バージョンです。

Visual Studio Code1.89.1
Java22
Node.js18.17.0
React18.3.1
TypeScript4.9.5

バックエンドの実装

Spring Bootプロジェクトの作成

VSCodeからSpring Bootプロジェクトを作成します。

項目備考
コマンドパレットSpring Initializr: Generate a Maven Projectビルドと依存関係管理にMavenを使用
Spring Boot version3.2.5SNAPSHOTがついていない = 安定版
prohect languageJava開発言語
Input Group Idcom.example生成するソースのパッケージ名(任意)
クラスやインタフェースを整理するために使われる
Input Artifact Idsimple-memo生成するプロジェクト名(任意)
プロジェクトの内容が分かる名前が良い
packaging typeJarデプロイ時にプログラムをまとめる方法の1つ
EC2やコンテナとの相性が良い
Java version17Javaのバージョン
17は長期サポートが対象のバージョンです。
(バージョンの違いは不勉強です…)
dependenciesSpring Webアノテーション(@RequestMappingや@GetMapping)を提供するなどWebアプリケーションの開発を効率化する機能の集合
(後で追加できるため最初は最小限)

Modelの実装

modelフォルダにMemoクラスとしてMemo.javaを作成します。

以下のフィールドを持ち、それぞれのgetterとsetterを記述します。

  • id: メモを一意に識別する情報
  • content: メモの内容
  • createdAt: 作成日時
  • updatedAt: 更新日時
src/main/java/com/example/simplememo/model/Memo.java
package com.example.simplememo.model;

import java.time.LocalDateTime;

public class Memo {
  private int id;
  private String content;
  private LocalDateTime createdAt;
  private LocalDateTime updatedAt;

  public Memo(int id, String content, LocalDateTime createdAt, LocalDateTime updatedAt) {
    this.id = id;
    this.content = content;
    this.createdAt = createdAt;
    this.updatedAt = updatedAt;
  }
  
  // フィールドのGetterとSetterの定義は省略
}

Lombokの@Dataを使えばgetterとsetterの記述が不要になるみたいです。

次の改修で導入してみたいと思います。(今回はできるだけ標準機能を使用)

Controllerの実装

controllerフォルダにSimpleMemoController.javaを作成します。

作成、取得、更新、削除などの操作を想定してメソッドを作成します。

src/main/java/com/example/simplememo/controller/SimpleMemoController.java
// 必要なライブラリ・パッケージについては省略

@RestController // JSONやXMLなどのデータを返すコントローラーであることを示す
@RequestMapping("/api/memos") // 個別のメソッドに"api/memos/create"と書く必要がなくなる
@CrossOrigin(origins = "http://localhost:3000") // 開発環境におけるCORSを許可する
public class SimpleMemoController {

  private List<Memo> allMemos = new ArrayList<Memo>();
  private int nextId = 0; // 削除されたメモIDと重複しないように次のメモのIDを保持する

  @GetMapping
  public ResponseEntity<List<Memo>> getAllMemos() {
    return ResponseEntity.ok(allMemos);
  }

  @PostMapping
  public ResponseEntity<Integer> createMemo(@RequestBody String content) {
    LocalDateTime now = LocalDateTime.now();
    Memo newMemo = new Memo(nextId++, content, now, now);
    allMemos.add(newMemo);
    return ResponseEntity.ok(newMemo.getId());
}

  @PutMapping("/{id}")
  public ResponseEntity<Void> updateMemo(@PathVariable int id, @RequestBody String content) {
    LocalDateTime now = LocalDateTime.now();
    Memo memoToUpdate = findMemoById(id);
    if (memoToUpdate != null) {
        memoToUpdate.setContent(content);
        memoToUpdate.setUpdatedAt(now);
        return ResponseEntity.ok().build();
    } else {
        return ResponseEntity.notFound().build();
    }
}

  @DeleteMapping("/{id}")
  public ResponseEntity<Void> deleteMemo(@PathVariable int id) {
    Memo memoToDelete = findMemoById(id);
    if (memoToDelete != null) {
        allMemos.remove(memoToDelete);
        return ResponseEntity.noContent().build(); // 物理削除は204
    } else {
        return ResponseEntity.notFound().build();
    }
}

  private Memo findMemoById(int id) {
    for (Memo memo : allMemos) {
      if (memo.getId() == id) {
        return memo;
      }
    }
    return null;
  }

}

今回、Reactはhttp://localhost:3000で実行し、Spring Bootはhttp://localhost:8080で実行します。

このように異なるポートにアクセスすることを「クロスポートリクエスト」と言います。

この「クロスポートリクエスト」は通常許可されていないため、許可する必要があります。

そのためにAccess-Control-Allow-OriginヘッダーというHTTPレスポンスヘッダーをサーバー側からのレスポンスに追加します。

今回はSpring BootでCORSを設定するために、コントローラクラスに@CrossOrigin(origins = "http://localhost:3000")を設定しています。

これを設定することで、http://localhost:3000からの通信を許可できます。

動作確認

curlコマンドで確認します。

確認ポイントは以下の通りです。

  1. レスポンスボディの確認
    • 追加・修正・削除・表示ができるか
    • 修正日時が更新されているか
    • IDが削除されたメモと重複していないか
  2. レスポンスヘッダの確認
    • 正常時のステータスコード
    • 異常時のステータスコード(存在しないID)
【ミニコラム:curlコマンドのオプション】
-s ... 「silent(静か)」モードを有効に
-X ... HTTPメソッドを明示
-H ... リクエストがJSON形式であることを明示
-d ... JSON形式のリクエストボディ
jq ... JSONのレスポンスを見やすく表示
-i ... ステータスコードを表示
Zsh
######################
# 1.レスポンスボディの確認
######################
# メモを作成
$ curl -s -X POST -H "Content-Type: application/json" -d 'サンプル0' http://localhost:8080/api/memos 
0

# もう一つメモを作成
$ curl -s -X POST -H "Content-Type: application/json" -d 'サンプル1' http://localhost:8080/api/memos
1

# メモ一覧を表示
$ curl -s -X GET http://localhost:8080/api/memos | jq
[
  {
    "id": 0,
    "content": "サンプル0",
    "createdAt": "2024-05-18T09:49:20.405343",
    "updatedAt": "2024-05-18T09:49:20.405343"
  },
  {
    "id": 1,
    "content": "サンプル1",
    "createdAt": "2024-05-18T09:49:28.844332",
    "updatedAt": "2024-05-18T09:49:28.844332"
  }
]

# 最初のメモを修正
$ curl -s -X PUT -H "Content-Type: application/json" -d '修正されたサンプル0' http://localhost:8080/api/memos/0

# 更新後のメモ一覧を表示(更新日時が反映されている)
$ curl -s -X GET http://localhost:8080/api/memos | jq
[
  {
    "id": 0,
    "content": "修正されたサンプル0",
    "createdAt": "2024-05-18T09:49:20.405343",
    "updatedAt": "2024-05-18T09:51:52.276221"
  },
  {
    "id": 1,
    "content": "サンプル1",
    "createdAt": "2024-05-18T09:49:28.844332",
    "updatedAt": "2024-05-18T09:49:28.844332"
  }
]

# 2番目のメモを削除
$ curl -s -X DELETE http://localhost:8080/api/memos/1

# 削除後のメモ一覧を表示
$ curl -s -X GET http://localhost:8080/api/memos | jq
[
  {
    "id": 0,
    "content": "修正されたサンプル0",
    "createdAt": "2024-05-18T09:49:20.405343",
    "updatedAt": "2024-05-18T09:51:52.276221"
  }
]

# さらに新しいメモを作成
$ curl -s -X POST -H "Content-Type: application/json" -d 'サンプル2' http://localhost:8080/api/memos
2

# 削除したメモの次のidになっていることを確認
$ curl -s -X GET http://localhost:8080/api/memos | jq
[
  {
    "id": 0,
    "content": "修正されたサンプル0",
    "createdAt": "2024-05-18T09:49:20.405343",
    "updatedAt": "2024-05-18T09:51:52.276221"
  },
  {
    "id": 2,
    "content": "サンプル2",
    "createdAt": "2024-05-18T09:52:41.849866",
    "updatedAt": "2024-05-18T09:52:41.849866"
  }
]
######################
# 2.レスポンスヘッダの確認
######################
# 表示(正常)
$ curl -i -X GET http://localhost:8080/api/memos    
HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 18 May 2024 01:07:10 GMT

# 追加(正常)
$ curl -i -X POST -H "Content-Type: application/json" -d 'サンプル3' http://localhost:8080/api/memos

HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 18 May 2024 01:10:08 GMT

# 修正(正常)
$ curl -i -X PUT -H "Content-Type: application/json" -d '修正されたサンプル3' http://localhost:8080/api/memos/3
HTTP/1.1 200 
Content-Length: 0
Date: Sat, 18 May 2024 01:12:55 GMT

# 修正(異常:存在しないID)
$ curl -i -X PUT -H "Content-Type: application/json" -d '修正されたサンプル4' http://localhost:8080/api/memos/4
HTTP/1.1 404 
Content-Length: 0
Date: Sat, 18 May 2024 01:13:37 GMT

# 削除(正常)
$ curl -i -X DELETE http://localhost:8080/api/memos/3
HTTP/1.1 204 
Date: Sat, 18 May 2024 01:15:39 GMT

# 削除(異常:存在しないID)
$ curl -i -X DELETE http://localhost:8080/api/memos/4
HTTP/1.1 404 
Content-Length: 0
Date: Sat, 18 May 2024 01:17:31 GMT

(ここまで確認すれば大丈夫でしょう)

余談:chromeでの動作確認

実は上記の動作確認ですが、GETはchromeで簡単に確認できるみたいです。

http://localhost:8080/api/memosとchromeで検索すると以下の画面が表示されます。

さらに左上の「プリティプリント」をクリックすると、JSONが整形されて表示されます。

chromeの標準機能であるようで、拡張機能を追加しなくても良いのは便利ですね。

バックエンドのリポジトリです。

GitHub - inaka-de-mac/simple-memo-backend
Contribute to inaka-de-mac/simple-memo-backend development by creating an account on GitHub.

フロントエンドの実装

React(TS)プロジェクトの作成

今回はTypeScriptを使います。

今後、別のEC2インスタンスやECSにデプロイすることを考え、以下の構成にします。

03_SimpleMemo(親ディレクトリ)
├─ simple-memo-frontend(React)
│  ├─ src
│  ├─ node_modules
│  ├─ package.json
│  └─ etc...
└─ simple-memo-backend(SpringBoot)
   ├─ src
   ├─ target
   ├─ pom.xml
   └─ etc...

以下のコードでTypeScriptのReactプロジェクトを作成します。

Zsh
npx create-react-app simple-memo --template typescript

デバッグ設定

VSCodeでReactのデバッグを行うための設定を行います。

デバッグを行うための設定はデバッグ構成ファイル「launch.json」に記述されます。

launch.jsonに直接記述しても良いですが、以下の手順で自動的に作成することもできます。

  1. VSCodeの「実行」>「構成の追加」をクリックします。
  2. ポップアップメニューから「node.js」を選択します。
  3. サイドバーの「実行とデバッグ」タブを開いて実行ボタンの右側のプルダウンリストからNode.jsを選択します。
  4. 「起動構成の選択」は「スクリプトの実行: start」を選択します。

これで以下のように自動記述されます。

JSON
{
  "version": "0.2.0", // launch.jsonのバージョン(記述の解釈方法の指定)
  "configurations": [ // デバッグ構成のリスト(複数設定できる)
    {
      "type": "node-terminal",       // Node.jsをVSCodeのターミナルで起動する
      "name": "スクリプトの実行: start", // ユーザーがデバッグ構成を識別するための名前
      "request": "launch",           // launch = 新しいデバッグセッションを開始する
      "command": "npm run start",    // デバッグボタンを押した時に実行するコマンド 
      "cwd": "${workspaceFolder}"    // コマンドを実行する作業ディレクトリ (現在のワークスペースフォルダー)
    }
  ]
}

実質的には指定の場所で「npm run start」をしているだけです。

APIを呼び出す処理を実装

バックエンドとの接続部分のみ抜粋します。

コードはGithubにあげてあります。

GitHub - inaka-de-mac/simple-memo-frontend
Contribute to inaka-de-mac/simple-memo-frontend development by creating an account on GitHub.
src/App.tsx
// GET:メモ一覧を取得
const fetchMemos = async () => {
  // API通信
  try {
    const response = await fetch("http://localhost:8080/api/memos");
    if (!response.ok) {
      throw new Error("Failed to fetch memos");
    }
    const memos = await response.json();
    // バックエンドと同期した編集前のメモ
    setOriginalMemos(memos);
    // フロントで修正したバックエンド反映前のメモ
    setCurrentMemos(JSON.parse(JSON.stringify(memos)));
  } catch (error) {
    console.error("Error fetching memos:", error);
  }
};
  
// PUT・POST:メモを更新・作成
const handleMemoUpdate = async (targetMemo: Memo) => {
  // 空メモの場合は終了(空白文字のみも終了の対象)
  if (targetMemo?.content.trim() === "") {
    setCurrentMemos(originalMemos);
    return;
  }
  
  if (targetMemo.createdAt !== "") {
    // 修正
    const isUpdating = originalMemos.some(
      (memo) =>
        memo.id === targetMemo.id && memo.content !== targetMemo.content
    );
    // 変更されてなかったら終了
    if (!isUpdating) {
      return;
    }
    // API通信
    try {
      const response = await fetch(
        `http://localhost:8080/api/memos/${targetMemo.id}`,
        {
          method: "PUT",
          headers: {
            "Content-Type": "application/json",
          },
          body: targetMemo.content,
        }
      );
      if (!response.ok) {
        throw new Error(`Failed to update memos id: ${targetMemo.id}`);
      }
      // 反映後のメモ一覧を取得
      fetchMemos();
    } catch (error) {
      console.error("Error updating memo:", error);
    }
  } else {
    // 登録
    try {
      const response = await fetch("http://localhost:8080/api/memos", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: targetMemo.content,
      });
      if (!response.ok) {
        throw new Error("Failed to add memos");
      }
      // 反映後のメモ一覧を取得
      fetchMemos();
      // 新規登録用の変数を初期化
      setAddingMemo(initialMemo);
    } catch (error) {
      console.error("Error adding memo:", error);
    }
  }
};

Chrome開発者ツールに以下のエラーが発生しました。

Uncaught (in promise) Error: A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received

Promise内での非同期処理に関するエラーですが、調べてみるとChromeの拡張機能が関係しているそうでした。

特にDeepLやGoogle翻訳などの翻訳機能による影響らしく、入っていたGoogle翻訳を削除したところエラーは出なくなりました。

【UIライブラリ:MUI】
ゴミ箱アイコンはMaterial-UIを使用しています。
MUIはGoogleが提供しているReact用のUIコンポーネントライブラリです。
以下のコマンドでインストールできます。
npm install @mui/icons-material @mui/material @emotion/styled @emotion/react

動作確認

メモの新規追加・表示・修正・削除を確認しました。

まとめ

以上が、Spring BootとReactのみで作る簡単メモアプリの概要でした。

Spring BootとReactを連携してみて、CORS問題の概要を(少しだけ)理解できた気がします。

フロントエンドの動きをいじるのが好きなのでログイン機能とか作りたいですが、まずはこの状態でAWSにデプロイしてみたいと思います。

参考文献

curlでヘッダ情報やHTTPステータスコードのみを出力する方法 - Qiita
curlコマンドでAPIリクエストを投げる際、ヘッダ情報を出力するオプションを忘れがちなのでメモ。ついでにHTTPステータスコードのみを出力させる方法も調べてみた。レスポンスボディのみを取得する場合…
200 or 204 どっちを使うか - Qiita
PUTとDELETEメソッドで200返すか204返すか悩んだのでそのメモ200返すか204を返すか大体の方針が見えるはず!204についてHTTPステータスコードの204 No Content…
Spring MVC で CORS 設定 - Qiita
はじめにAngular と Spring Boot による SPA 開発時、クライアントサイドとサーバサイドアプリを、別々のマシンにホスティングする場合の考慮事項をまとめた記事です。Same…
【Spring Boot】CORSの設定
CORSについてとSpring BootにおけるCORSの設定方法について説明します。
【React.js】SpringBootで作成したAPIを呼び出す方法 - Qiita
はじめに今回は、SpringBootで作成したRestAPIをReactで呼び出す方法について解説します。SpringBootにReactを導入する方法を調べても、思ったようなものを見つけられな…

コメント