こんにちは。じゅんです。
今回はDBを伴わない簡単なメモアプリを作成してみたいと思います。
Spring BootとReactを連携させる方法を習得することを目的としています。
学習記録も兼ねた記事一覧をこちらの記事にまとめています。
事前準備
各バージョンです。
Visual Studio Code | 1.89.1 |
Java | 22 |
Node.js | 18.17.0 |
React | 18.3.1 |
TypeScript | 4.9.5 |
バックエンドの実装
Spring Bootプロジェクトの作成
VSCodeからSpring Bootプロジェクトを作成します。
項目 | 値 | 備考 |
コマンドパレット | Spring Initializr: Generate a Maven Project | ビルドと依存関係管理にMavenを使用 |
Spring Boot version | 3.2.5 | SNAPSHOTがついていない = 安定版 |
prohect language | Java | 開発言語 |
Input Group Id | com.example | 生成するソースのパッケージ名(任意) クラスやインタフェースを整理するために使われる |
Input Artifact Id | simple-memo | 生成するプロジェクト名(任意) プロジェクトの内容が分かる名前が良い |
packaging type | Jar | デプロイ時にプログラムをまとめる方法の1つ EC2やコンテナとの相性が良い |
Java version | 17 | Javaのバージョン 17は長期サポートが対象のバージョンです。 (バージョンの違いは不勉強です…) |
dependencies | Spring Web | アノテーション(@RequestMappingや@GetMapping)を提供するなどWebアプリケーションの開発を効率化する機能の集合 (後で追加できるため最初は最小限) |
Modelの実装
modelフォルダにMemoクラスとしてMemo.java
を作成します。
以下のフィールドを持ち、それぞれのgetterとsetterを記述します。
- id: メモを一意に識別する情報
- content: メモの内容
- createdAt: 作成日時
- updatedAt: 更新日時
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
を作成します。
作成、取得、更新、削除などの操作を想定してメソッドを作成します。
// 必要なライブラリ・パッケージについては省略
@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コマンドで確認します。
確認ポイントは以下の通りです。
- レスポンスボディの確認
- 追加・修正・削除・表示ができるか
- 修正日時が更新されているか
- IDが削除されたメモと重複していないか
- レスポンスヘッダの確認
- 正常時のステータスコード
- 異常時のステータスコード(存在しないID)
【ミニコラム:curlコマンドのオプション】
-s ... 「silent(静か)」モードを有効に
-X ... HTTPメソッドを明示
-H ... リクエストがJSON形式であることを明示
-d ... JSON形式のリクエストボディ
jq ... JSONのレスポンスを見やすく表示
-i ... ステータスコードを表示
######################
# 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の標準機能であるようで、拡張機能を追加しなくても良いのは便利ですね。
バックエンドのリポジトリです。
フロントエンドの実装
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プロジェクトを作成します。
npx create-react-app simple-memo --template typescript
デバッグ設定
VSCodeでReactのデバッグを行うための設定を行います。
デバッグを行うための設定はデバッグ構成ファイル「launch.json」に記述されます。
launch.jsonに直接記述しても良いですが、以下の手順で自動的に作成することもできます。
- VSCodeの「実行」>「構成の追加」をクリックします。
- ポップアップメニューから「node.js」を選択します。
- サイドバーの「実行とデバッグ」タブを開いて実行ボタンの右側のプルダウンリストからNode.jsを選択します。
- 「起動構成の選択」は「スクリプトの実行: start」を選択します。
これで以下のように自動記述されます。
{
"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にあげてあります。
// 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にデプロイしてみたいと思います。
コメント