🦴

React で ToDo アプリを作って遊んでみた

2024/10/20に公開3

はじめに

フロントエンド周りの勉強もぼちぼちやりたいと思って最近少しずつ勉強している。今回の記事は勉強の結果をまとめたものであり、React で以下のような簡単な Todo アプリを作って遊ぶ。

今回作るアプリのリポジトリは以下。今回作成するソースコードはすべて Unlicense で配布しているのでご自由にご活用ください。一部チュートリアルなどから取得しているコードにはコード中に出典を明記しておりますので、そこだけ気をつけてください。

https://github.com/wsuzume/rabit

技術選定

私はそこまで頭がよくないので、本業の片手間の勉強でフルスタックのフレームワークをバリバリ使いこなすようなことはできない。フロントエンドの勉強にかけていられる時間はあまりないので、技術選定としても学習コストを下げることが第一になる。

というわけで Next.js や Vue.js のような変化が激しいイケイケのフレームワークは真っ先に排除される[1]

散々悩んだ結果、最終的に React + Vite + TypeScript という飾り気のない構成に落ち着いた

ピュアな React を用いることにした理由は、React は Next.js や Remix といった最近人気のフレームワークの根幹部分になっており、今後 10 年くらいはどのようにフレームワークが生え変わっても React の部分で勉強した知識は活かせるだろうという読みによる。

Vite は素人目に見てもピュアな React を用いて開発するのに必要なツールがうまくまとまっていて、諸々の作業が簡単になりそうだと感じる。仮に Vite が廃れても、この部分は似たようなツールが出てくるであろうと思い、あまり深くは考えていない。

TypeScript はもはやデファクトスタンダードなので、これを勉強することにあまり疑問の余地はない。以前は Elm に手を出していたこともあったが、そのフレームワークの中に収まりきらない部分は結局 JavaScript で書く必要があったし、ネット上にある情報の量を考慮しても、学習コストを下げるならやはりデファクトスタンダード側に寄せるほうがよいと判断した[2]

ちなみにバックエンドは golang + Echo + PostgeSQL で構成する[3]。より学習コストを下げるのであれば TypeScript をそのまま使えるという意味で node.js + Express + PostgreSQL などの構成が考えられる[4]し、機械学習を用いたサービスでは Python + FastAPI + Postgress のような構成も考えられる[5]

開発環境

趣味での開発なので手持ちの Macbook Air 1台の中に完結させる方向で調整している[6]

  • Macbook Air M1, 2020, メモリ16GB
  • macOS Sonoma 14.6.1
  • Docker Desktop 4.33.0

Linux であればツールのインストール方法を除いて本記事の手順をほとんど変更せずに再現できると思うし、Windows でも WSL + Docker の構成で再現できる範疇だと思う。

1. 雛形となるプロジェクトの作成

まずは雛形となるプロジェクトを作成する。この章の手順をすべて終えると、おおよそ以下のようなディレクトリ構成になる(説明に関係のないファイルは記していない)。

rabit
├── rabit-client
│   ├── dist        ... ビルド後のリソースが格納される
│   ├── index.html  ... ソースコードとなるのは主にここより下 ↓ のファイル
│   ├── public
│   │   └── vite.svg
│   └── src
│       ├── App.css
│       ├── App.tsx
│       ├── assets
│       │   └── react.svg
│       ├── index.css
│       ├── main.tsx
│       └── vite-env.d.ts
├── rabit-db
│   ├── Dockerfile
│   └── docker-compose.yml
└── rabit-server
    ├── Makefile
    ├── go.mod
    ├── go.sum
    ├── server     ... ビルド後のバイナリ
    └── server.go  ... ソースコード

1.1. nvm による node の仮想化

macOS にはデフォルトでは node がインストールされていないようだったのでインストールする。生の環境にインストールするのは怖すぎるので nvm で仮想化しておく[7]

インストール方法は時間経過や環境によって変わる可能性があるので公式で確認すること

https://github.com/nvm-sh/nvm

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
nvm install node --latest-npm

1.2. goenv による golang 環境の仮想化

golang の環境も goenv というツールで仮想化できるようなので仮想化しておく。

インストール方法は環境によって変わるので公式で確認すること

https://github.com/go-nv/goenv/tree/master

brew install goenv
echo 'export GOENV_ROOT="$HOME/.goenv"' >> ~/.zshenv
echo 'export PATH="$GOENV_ROOT/bin:$PATH"' >> ~/.zshenv
echo 'eval "$(goenv init -)"' >> ~/.zshrc
echo 'export PATH="$GOROOT/bin:$PATH"' >> ~/.zshrc
echo 'export PATH="$PATH:$GOPATH/bin"' >> ~/.zshrc

golang のバージョンは Docker の公式イメージのうちで最新のものにしておくと、後々 Docker に移植する際にバージョンのミスマッチが発生せず楽だと思われる。記事執筆時点ではバージョン 1.23.1 が最新である。

# インストール
goenv install 1.23.1
# グローバルに設定
goenv global 1.23.1
# 変更を反映するための処理
goenv rehash

うまく設定できているか確認する。

$ goenv prefix
/Users/josh/.goenv/versions/1.23.1
$ go version
go version go1.23.1 darwin/arm64

1.3. フロントエンド:プロジェクトの初期化

Vite を使ってフロントエンドのプロジェクトテンプレートを生成する。

cd rabit
npm create vite@latest rabit-client -- --template react-swc-ts

テンプレート生成時に以下の操作をするよう促されるので、この操作によりプロジェクトにライブラリを追加する。

cd rabit-client
npm install

また、以下のコマンドで開発用のサーバーを立てることができる。

npm run dev

表示されているアドレスにブラウザからアクセスすると以下のようなサイトが表示される。

さらに、以下のコマンドでプロジェクトがビルドされて dist ディレクトリに配置される。

npm run build

この dist ディレクトリの中身を適当な Web サーバーでホストすればユーザーにサービスとして提供することができる。

この時点で以下の React の公式チュートリアルは進められる状態なので、やりたい人はやってみるとよい。

https://ja.react.dev/blog/2023/03/16/introducing-react-dev

1.4. バックエンド:プロジェクトの初期化

次に golang でバックエンドのサーバーを構築する。

cd rabit
mkdir rabit-server

golang のモジュールとしてディレクトリを初期化し、Echo をインストールする。

go mod init rabit-server
go get github.com/labstack/echo/v4

以下のファイルを作成する。サーバーのソースコードはとりあえず Echo のチュートリアルのコピペである。

Makefile
build:
	go build -o server server.go
server.go
// from https://echo.labstack.com/docs/quick-start

package main

import (
	"net/http"

	"github.com/labstack/echo/v4"
)

func main() {
	e := echo.New()
	e.GET("/", func(c echo.Context) error {
		return c.String(http.StatusOK, "Hello, World!")
	})
	e.Logger.Fatal(e.Start(":1323"))
}

以下のコマンドでソースコードをビルドする。

make build

ビルドして生成されたバイナリを ./server のように実行すると、以下が表示される。

表示されているアドレスにアクセスすると Hello, World! が表示される。

1.5. データベース: Docker による構築

データベースはあとでサーバーに移植することを考慮して Docker で構築する。まだ Docker をインストールしていなければ、以下からダウンロードしてインストールし、起動しておく。

https://www.docker.com/ja-jp/products/docker-desktop/

PostgreSQL の Docker 公式イメージは以下である。

https://hub.docker.com/_/postgres

ディレクトリを作成する。

cd rabit
mkdir rabit-db

ディレクトリの中に以下のファイルを作成する。

Dockerfile
FROM postgres:16.4
docker-compose.yml
services:
  db:
    container_name: rabit_postgres_db
    restart: always
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      POSTGRES_PASSWORD: password

イメージをビルドする。

# ビルド
docker compose build

起動して、起動ログを確認する。

$ docker compose up -d
[+] Running 2/2
 ✔ Network rabit-db_default     Created                                    0.0s
 ✔ Container rabit_postgres_db  Started
$ docker compose logs
rabit_postgres_db  | The files belonging to this database system will be owned by user "postgres".
rabit_postgres_db  | This user must also own the server process.
rabit_postgres_db  |
rabit_postgres_db  | The database cluster will be initialized with locale "en_US.utf8".
rabit_postgres_db  | The default database encoding has accordingly been set to "UTF8".
rabit_postgres_db  | The default text search configuration will be set to "english".
rabit_postgres_db  |
rabit_postgres_db  | Data page checksums are disabled.
rabit_postgres_db  |
rabit_postgres_db  | fixing permissions on existing directory /var/lib/postgresql/data ... ok
rabit_postgres_db  | creating subdirectories ... ok
...

ちなみに停止したければ以下。

docker compose down

データベースのコンテナを起動しているときに以下のコマンドを実行すると psql を用いてコンテナの PostgreSQL に接続できる。

$ docker exec -it rabit_postgres_db psql -U postgres
psql (16.4 (Debian 16.4-1.pgdg120+1))
Type "help" for help.

postgres=#

試しにデータベース内のユーザーを一覧取得してみる。

psql
postgres=# select * from pg_user;
output
 usename  | usesysid | usecreatedb | usesuper | userepl | usebypassrls |  passwd  | valuntil | useconfig
----------+----------+-------------+----------+---------+--------------+----------+----------+-----------
 postgres |       10 | t           | t        | t       | t            | ******** |          |
(1 row)

2. それぞれのコンポーネントを結合する

次にここまでで作成した各コンポーネントを結合する。

2.1. フロントエンドとバックエンドを結合する

結合するというよりは、ビルドした React のコードを golang のサーバーでホストするだけである。

2.1.1. App.tsx の編集

ホストする際のちょっとした面倒を省くために、vite.svg の配置を変更しておく。

cd rabit
mv rabit-client/public/vite.svg rabit-client/src/assets/vite.svg
rabit-client/src/App.tsx
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'    // ← ここを変更
import './App.css'

...

ビルドする。

cd rabit-client
npm run build

2.1.2. dist の配置

React のビルド結果である rabit-client/dist と、golang のビルド結果である rabit-server/serverbin に配置する。

以下の Makefilerabit/Makefile に記述する。build_app スクリプトは rabit-clientrabit-server をビルドして、結果を rabit/bin に格納する。ついでに書いた run_app スクリプトは rabit/bin にある server のバイナリを実行する。

rabit/Makefile
build_app:
	cd rabit-client && npm run build
	cd rabit-server && make build
	rm -rf bin
	mkdir -p bin
	cp -r rabit-client/dist bin
	cp rabit-server/server bin

run_app:
	cd bin && ./server

make build_app を実行すると以下のようなディレクトリが作成される。

rabit
├── Makefile
└── bin
    ├── dist
    │   ├── assets
    │   │   ├── index-BStUhQb-.js
    │   │   ├── index-DiwrgTda.css
    │   │   └── react-CHdo91hT.svg
    │   └── index.html
    └── server

2.1.3. server.go の編集

最後に golang 側のサーバーで rabit/bin/dist の中身をホストするようにコードを書き換える。

rabit-server/server.go
package main

import (
	"github.com/labstack/echo/v4"
)

func main() {
	e := echo.New()
	e.File("/", "dist/index.html")
	e.Static("/assets", "dist/assets")
	e.Logger.Fatal(e.Start(":1323"))
}

ビルドする。

cd rabit
make build_app

2.1.4. 結果の確認

localhost でちゃんと動くことを確認する。

cd rabit
make run_app


2.2. バックエンドとデータベースを結合する

golang に PostgreSQL のドライバーをインストールして、PostgreSQL に接続できるように設定する。

2.2.1. PostgreSQL の設定

いまのままだと PostgreSQL は Docker のネットワーク内に隔離されていて、同じ Docker ネットワーク内に存在するコンテナからしかアクセスすることができない。

Docker ネットワークの外部からアクセス可能にするにはホスト側のポートにバインドする。

PostgreSQL のデフォルトポートは 5432 である。ホスト側の 5432 番にバインドする(ホスト側で既に 5432 番を使用しているなら変えてもよい)。

rabit-db/docker-compose.yml
services:
  db:
    container_name: rabit_postgres_db
    restart: always
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      POSTGRES_PASSWORD: password
    ports:
      - '5432:5432'    # ← これを追加

以下のコマンドで起動する。

cd rabit-db
docker compose up -d

コンテナを一覧してポートバインドの状況を確認する。

$ docker container ls
CONTAINER ID   IMAGE         COMMAND                   CREATED         STATUS         PORTS                    NAMES
69e027b83ee1   rabit-db-db   "docker-entrypoint.s…"   4 minutes ago   Up 4 minutes   0.0.0.0:5432->5432/tcp   rabit_postgres_db

PORTS の項目を確認すると 0.0.0.0:5432->5432/tcp となっているので、どのアドレスからの通信も受け入れる状態(0.0.0.0)であり、ホスト側の 5432 番ポートがコンテナ側の 5432 番ポートにバインドされていることが分かる。

ホスト側に psql がインストールされていない限り簡易的な接続テストはできないので、golang から接続して確かめてみる。

2.2.2. golang からの接続テスト

golang に PostgreSQL のドライバーをインストールする。

cd rabit-server
go get github.com/lib/pq

ChatGPT と相談しながら作成した接続テストのテストコードは以下。TestMain はテストの初期化に用いる関数で、ここでまず ping によりデータベースとの疎通を確認し、疎通が確認できれば他のテストコードを m.Run() で実行できる。いまは ping だけでまだ他のテストは追加しない。

rabit-server/postgres_test.go
package main

import (
	"database/sql"
	"log"
	"os"
	"testing"

	_ "github.com/lib/pq"
)

var db *sql.DB

// テスト前にPostgreSQLに接続し、Pingをテスト
func TestMain(m *testing.M) {
	// PostgreSQLの接続情報
	connStr := "user=postgres password=password dbname=postgres sslmode=disable"

	var err error
	db, err = sql.Open("postgres", connStr)
	if err != nil {
		log.Fatalf("データベース接続エラー: %v", err)
	}

	// deferでDB接続をクローズ
	defer db.Close()

	// 接続確認
	err = db.Ping()
	if err != nil {
		log.Fatalf("データベースに接続できませんでした: %v", err)
	}

	// テストの実行
	code := m.Run()

	// 終了コードを返す
	os.Exit(code)
}

テストを実行してみる。

$ cd rabit-server
$ go test
testing: warning: no tests to run
PASS
ok  	rabit-server	0.251s

TestMain だけなので no tests to run となったが、データベースへの接続に失敗していればエラー終了するので、疎通確認が取れた。

3. ToDo アプリの各コンポーネントを実装する

以上でアプリ作成の準備はできた。あとは煮るなり焼くなり好きにすればよい。

素振りとしてあまり複雑にするつもりはないので、基本の CRUD(Create, Read, Update, Delete)ができればよいと思う。シンプルに ToDo のリストとそれを構成するアイテムのみを作成することにして、

  • Create: リストにタスクを追加する
  • Read: タスクの詳細を閲覧する
  • Update: タスクの詳細や状態を更新する
  • Delete: タスクをリストから削除する

の4つの機能に相当する機能を実装する[8]。RESTful なサービスであれば今回実装する機能の応用で構築することができるだろう。

今回はフロントエンド、バックエンド、データベースの順で作成していき、それに伴って各コンポーネント間で受け渡されるデータの形式も順に決まっていく。

もし研修などで本記事を用いており、別々のチームがそれぞれのコンポーネントを別々に作成する場合には、受け渡されるデータのフォーマットを前もって定義し、各チーム間で共有しておくという手順が必要になる。そのような作業は初心者には難しいので、各チームが準拠すべきデータフォーマットの定義はチューターが示すべきであろう。

3.1. フロントエンド

フロントエンド部分を作成する。この部分だけであれば ChatGPT を頼りつつ React の公式チュートリアルで得られる知識を応用すれば作成可能である。また、CSS については MDN にある資料をざっと眺めて、方針についてはこちらで指示しつつ ChatGPT に8割方書かせた[9]

https://ja.react.dev/learn/tutorial-tic-tac-toe

https://developer.mozilla.org/ja/docs/Web/CSS/Syntax

実装されている機能はシンプルに以下の3つとした。

  • + ボタンのある空のカードをクリックすると New Task と書かれたカードが追加される。
  • - ボタンを押すとカードを削除できる。
  • カードに書かれた文字列をクリックすると書かれている内容を編集できる。

作業前に rabit-client/src/assets の中にある React と Vite のロゴは必要ないので削除しておく。

rm rabit-client/src/assets/*

あとは以下のように各ファイルを書き換える。

index.css

もとの index.css のうち中間部分を削除して、最低限の部分のみ残した。

index.css
:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

@media (prefers-color-scheme: light) {
  :root {
    color: #213547;
    background-color: #ffffff;
  }
  a:hover {
    color: #747bff;
  }
  button {
    background-color: #f9f9f9;
  }
}
App.css

ほとんどを削除して、自分で作成したコントロールを修飾するための記述を追加する。

App.css
#root {
  max-width: 1280px;
  margin: 0 auto;
  padding: 2rem;
  text-align: center;
}

.todo-grid-wrapper {
  width: 600px;
  margin: 1rem auto;
  display: grid;
}

.todo-grid-item {
  width: 600px;
  height: 60px;
  border: 1px solid gray;
  border-radius: 8px;
  padding: 1rem;
  text-align: left;
  box-sizing: border-box;
  display: flex;
  align-items: center;
}

.todo-grid-item-button {
  width: 22px;
  height: 22px;
  border: 1px solid gray;
  border-radius: 2px;
  text-align: center;
}

.todo-grid-item-title {
  margin: 10px;
}

.todo-grid-item-input {
  width: 300px;
  height: 30px;
  margin-left: 32px;
}
App.tsx

まだバックエンドとの通信は行わない。ToDoリストに書き込まれたデータは todotable に保存され、この書き換えがバックエンドとの通信をエミュレートしている。

App.tsx
import { useState } from 'react'
import './App.css'

interface ToDoSingleContent {
  id: number;
  title: string;
};

interface ToDoListItem {
  key: number;
  content: ToDoSingleContent;
};

type ToDoTable = {
  [index: number]: ToDoListItem;
};

const todotable: ToDoTable = {
  0: { key: 1, content: { id: 0, title: '部屋を片付ける' }},
  1: { key: 2, content: { id: 1, title: '友達にメッセージを返す' }},
  2: { key: 3, content: { id: 2, title: '昼寝する' }},
  3: { key: 4, content: { id: 3, title: '夕飯を作る' }},
  4: { key: 5, content: { id: 4, title: 'ゴールデンレトリーバーと遊ぶ' }},
};

const ToDoList: React.FC = () => {
  const [table, setTable] = useState(todotable);

  const updateTable = (id: number, title: string) => {
    const newTable = { ...table };
    newTable[id].content = { id, title };

    setTable(newTable);
    console.log(table); // デバッグ用
  };

  const addToDoCard = () => {
    const maxId = 
      (Object.keys(table).length == 0) ? 0 : Object.keys(table).map(Number).reduce((a, b) => Math.max(a, b));
      
    const newId = maxId + 1;
    const newKey = newId + 1;

    const newTable = { ...table };
    newTable[newId] = { key: newKey, content: { id: newId, title: 'New Task' } };

    setTable(newTable);
    console.log(table); // デバッグ用
  };

  const deleteToDoCard = (id: number) => {
    const newTable = { ...table };
    delete newTable[id];

    setTable(newTable);
    console.log(table); // デバッグ用
  };

  return (
    <>
      <div className='todo-grid-wrapper'>
        {Object.values(table).map(({key, content}) => (
          <ToDoCard
            key={key}
            id={content.id}
            title={content.title}
            updateTable={updateTable}
            deleteToDoCard={deleteToDoCard}
          />
        ))}
        <AddToDoCardButton addToDoCard={addToDoCard}/>
      </div>
    </>
  );
}

interface ToDoCardProps {
  id: number;
  title: string;
  updateTable: (id: number, title: string) => void;
  deleteToDoCard: (id: number) => void;
};

const ToDoCard: React.FC<ToDoCardProps> = ({ id, title, updateTable, deleteToDoCard }) => {
  // key は ToDoList が要素を管理するために React が使用する値なのでここでは受け取らない
  const [isEditing, setIsEditing] = useState(false);
  const [inputValue, setInputValue] = useState(title); // 入力値を状態管理

  const handleTitleClick = () => {
    setIsEditing(true); // クリックされたときに入力状態にする
  };
  
  const handleTitleBlur = () => {
    setIsEditing(false); // フォーカスが外れたときに再度 <div> に戻る

    if (inputValue == '') {
      // inputValue が空だと編集できなくなるので
      // 空の場合は埋め草を入れておく
      title = 'New Task';
      setInputValue(title);
      updateTable(id, title);
    } else {
      updateTable(id, inputValue);
    }
  };

  const handleDeleteButtonClick = () => {
    deleteToDoCard(id);
  };

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value); // 入力値をリアルタイムに状態に反映
  };

  const viewer = (
    <>
      <div className='todo-grid-item'>
        <button className='todo-grid-item-button' onClick={handleDeleteButtonClick}>-</button>
        <span className='todo-grid-item-title' onClick={handleTitleClick}>{inputValue}</span>
      </div>
    </>
  );

  const editor = (
    <>
      <div className='todo-grid-item'>
        <input
          className='todo-grid-item-input'
          type='text'
          value={inputValue}
          onChange={handleInputChange}
          autoFocus
          onBlur={handleTitleBlur}
        />
      </div>
    </>
  );

  return (
    <>
      { isEditing ? editor : viewer }
    </>
  );
}

interface AddToDoCardButtonProps {
  addToDoCard: () => void;
};

const AddToDoCardButton: React.FC<AddToDoCardButtonProps> = ({addToDoCard}) => {
  const handleOnClick = () => {
    addToDoCard();
  };

  return (
    <>
      <div className='todo-grid-item' onClick={handleOnClick}>
        <button className='todo-grid-item-button'>+</button>
      </div>
    </>
  )
}

function App() {
  return (
    <>
      <h1>Rabit ToDo</h1>
      <ToDoList/>
    </>
  );
}

export default App

バックエンド側にとって重要な情報は以下。key は React が個々の ToDoCard を区別するための情報で、id はデータベース上で ToDo のアイテムを区別するための情報である。

今回のケースでは両者ともそれ以上の役割を持たないのでどのように設定してもよいのだが、keyid が異なる情報であることを示すためにあえて1ずらしてある。key は本質的にはデータベースに保存する必要がない情報だが、たとえば ToDo のアイテムに対して並び替え機能を提供し、その表示順をデータベース側にも保存したいといった場合にはデータベース側に送信することもありえる。

const todotable: ToDoTable = {
  0: { key: 1, content: { id: 0, title: '部屋を片付ける' }},
  1: { key: 2, content: { id: 1, title: '友達にメッセージを返す' }},
  2: { key: 3, content: { id: 2, title: '昼寝する' }},
  3: { key: 4, content: { id: 3, title: '夕飯を作る' }},
  4: { key: 5, content: { id: 4, title: 'ゴールデンレトリーバーと遊ぶ' }},
};

なお、インデックスは id と同じ値を用いてアクセスしやすくするためのものなので、バックエンドに送る際は取り除いて、以下の形式の JSON で送信する。テーブルの全体を送信することもあるだろうし、操作対象となる一部のアイテムのみを送信することもあるという想定にしておく。いずれにせよ、受け取った側は、受け取ったアイテムに対して指定された操作を適用するという実装にしておけばよい。

[
  { key: 1, content: { id: 0, title: '部屋を片付ける' }},
  { key: 2, content: { id: 1, title: '友達にメッセージを返す' }},
  { key: 3, content: { id: 2, title: '昼寝する' }},
  { key: 4, content: { id: 3, title: '夕飯を作る' }},
  { key: 5, content: { id: 4, title: 'ゴールデンレトリーバーと遊ぶ' }}
]

3.2. バックエンド

バックエンド側は REST API として提供する。今回の練習の中で行いたいのは CRUD(Create, Read, Update, Delete)の4種の操作である。対応する HTTP メソッドはおおよそ以下。

メソッド 意味 対応
GET リソースの取得 Read
POST 子リソースの作成、リソースへのデータ追加、その他処理 -
PUT リソースの更新、リソースの作成 Update, Create
DELETE リソースの削除 Delete

API のコールはすべて POST で済ませてしまうような実装もあると思うが、今回は HTTP のセマンティクスとして想定されている使い方に合わせてみようと思う[10]

なお、フロント側でどうやってサーバー側と通信しようかこねくり回してみたら、ここで作成する DELETE メソッドの出番はなくなった[11]。API を自分で叩くユーザー用のエンドポイントだと思って目をつぶってほしい。

以下のように server.go を編集してコンパイルする。

server.go
server.go
package main

import (
	"fmt"
	"net/http"

	"github.com/labstack/echo/v4"
)

type Todo struct {
	Key     int `json:"key"`
	Content struct {
		ID    int    `json:"id"`
		Title string `json:"title"`
	} `json:"content"`
}

// データベース代わり
var globalTodos = []Todo{}

func main() {
	e := echo.New()

	// フロントエンドの提供
	e.File("/", "dist/index.html")
	e.Static("/assets", "dist/assets")

	// ToDo のリストを取得する
	e.GET("/api/todos", func(c echo.Context) error {
		return c.JSON(http.StatusOK, globalTodos)
	})

	// ToDo のリストを更新する
	e.PUT("/api/todos", func(c echo.Context) error {
		var todos []Todo

		// 受け取った JSON を構造体にバインドする
		if err := c.Bind(&todos); err != nil {
			return c.JSON(http.StatusBadRequest, echo.Map{
				"message": "Invalid JSON",
			})
		}

		// デバッグ用:受け取ったデータを標準出力に表示する
		for _, todo := range todos {
			fmt.Printf("Key: %d, ID: %d, Title: %s\n", todo.Key, todo.Content.ID, todo.Content.Title)
		}

		// データベースへの保存をエミュレート
		globalTodos = todos

		// 更新された ToDo リストを返す
		return c.JSON(http.StatusOK, todos)
	})

	// id が一致する ToDo アイテムを削除する
	e.DELETE("/api/todos", func(c echo.Context) error {
		var todosToDelete []Todo

		// 受け取った JSON を構造体にバインドする
		if err := c.Bind(&todosToDelete); err != nil {
			return c.JSON(http.StatusBadRequest, echo.Map{
				"message": "Invalid JSON",
			})
		}

		// 一致する ID の Todo を削除
		for _, todoToDelete := range todosToDelete {
			for i, todo := range globalTodos {
				if todo.Content.ID == todoToDelete.Content.ID {
					globalTodos = append(globalTodos[:i], globalTodos[i+1:]...)
					break
				}
			}
		}

		// 削除後の globalTodos を返す
		return c.JSON(http.StatusOK, globalTodos)
	})

	e.Logger.Fatal(e.Start(":1323"))
}

次に動作テストを行う。rabit-server/tests/put_todos.jsonrabit-server/test/delete_todos.json にそれぞれ以下のテスト用の JSON を配置する。

put_todos.json
[
  { "key": 1, "content": { "id": 0, "title": "部屋を片付ける" }},
  { "key": 2, "content": { "id": 1, "title": "友達にメッセージを返す" }},
  { "key": 3, "content": { "id": 2, "title": "昼寝する" }},
  { "key": 4, "content": { "id": 3, "title": "夕飯を作る" }},
  { "key": 5, "content": { "id": 4, "title": "ゴールデンレトリーバーと遊ぶ" }}
]
delete_todos.json
[
  { "key": 1, "content": { "id": 0, "title": "部屋を片付ける" }},
  { "key": 3, "content": { "id": 2, "title": "昼寝する" }},
  { "key": 5, "content": { "id": 4, "title": "ゴールデンレトリーバーと遊ぶ" }}
]

サーバーを起動したら、以下のコマンドで動作を確認することができる。

GET
curl -X GET http://localhost:1323/api/todos -H "Content-Type: application/json"
PUT
curl -X PUT http://localhost:1323/api/todos -H "Content-Type: application/json" --data @tests/put_todos.json
DELETE
curl -X DELETE http://localhost:1323/api/todos -H "Content-Type: application/json" --data @tests/delete_todos.json

3.3. フロントエンドとバックエンドを結合する

バックエンド側で JSON を処理できるようになったので、フロントエンドからバックエンドへリクエストを投げるように書き換える。React でこれをどう実装するかは何やら議論があるようだが、ぶっちゃけフロントエンドは詳しくないので、現状「コンポーネントを外部システムと同期させるための React フックです。」と明記されている useEffect を用いることにする[12]

https://ja.react.dev/reference/react/useEffect

useEffect の議論に関して要点をまとめると React のコンポーネント描画は純粋でなければならないようで、副作用はなるべくハンドラに分離してほしいとのことのように思える[13]

いろいろ考えた結果、今回はいままでに作成した React コードのロジックを極力変更せずに最小限のコード追加で望みの機能を実現するために、若干のマナー違反感は感じるが以下のような方針とした。

  • コンポーネントが初めて DOM に追加されるときはサーバーから ToDo リストを GET メソッドでフェッチしてくる
  • 以降は ToDoListtable を監視対象として、変更があればサーバーに table の全体を PUT メソッドで送信する

「コンポーネントを外部システムと同期」という役割を忠実に果たしているので、そこまでおかしな使い方はしていないと信じる。

App.tsx を以下のように編集してアプリケーションの全体をビルドする。

App.tsx
App.tsx
import { useEffect, useState } from 'react'
import './App.css'

interface ToDoSingleContent {
  id: number;
  title: string;
};

interface ToDoListItem {
  key: number;
  content: ToDoSingleContent;
};

type ToDoTable = {
  [index: number]: ToDoListItem;
};

// const todotable: ToDoTable = {
//   0: { key: 1, content: { id: 0, title: '部屋を片付ける' }},
//   1: { key: 2, content: { id: 1, title: '友達にメッセージを返す' }},
//   2: { key: 3, content: { id: 2, title: '昼寝する' }},
//   3: { key: 4, content: { id: 3, title: '夕飯を作る' }},
//   4: { key: 5, content: { id: 4, title: 'ゴールデンレトリーバーと遊ぶ' }},
// };

const ToDoList: React.FC = () => {
  const [table, setTable] = useState<ToDoTable>({});
  const [isInitialized, setIsInitialized] = useState<boolean>(false);

  // サーバー側から ToDo リストを取得する
  const fetchData = async () => {
    try {
      const response = await fetch('http://localhost:1323/api/todos'); // APIエンドポイントを指定
      if (!response.ok) {
        throw new Error('Network response was not ok');
      }
      const data = await response.json(); // JSON データをパース

      // content.id をインデックスとしてオブジェクトに変換
      const table = data.reduce((acc: ToDoTable, item: ToDoListItem) => {
        acc[item.content.id] = item;
        return acc;
      }, {});

      setTable(table);
      setIsInitialized(true);
    } catch (error) {
      console.error('Fetching error: ', error);
    }
  };

  // クライアント側で変更された table の状態をサーバーに送信する関数
  const sendData = async () => {
    try {
      const response = await fetch('http://localhost:1323/api/todos', {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(Object.values(table)), // テーブルの状態をサーバーに送信
      });
      if (!response.ok) {
        throw new Error('Failed to send data to the server');
      }
    } catch (error) {
      console.error('Sending error: ', error);
    }
  };

  // コンポーネントマウント時にサーバーから ToDo リストを取得する
  useEffect(() => {
    if (!isInitialized) {
      fetchData(); // 最初の1回だけサーバーからデータをフェッチ
    } else {
      sendData(); // table が変更されたときにサーバーに送信
    }
  }, [table]);

  const updateTable = (id: number, title: string) => {
    const newTable = { ...table };
    newTable[id].content = { id, title };

    setTable(newTable);
    console.log(table); // デバッグ用
  };

  const addToDoCard = () => {
    const maxId = 
      (Object.keys(table).length == 0) ? 0 : Object.keys(table).map(Number).reduce((a, b) => Math.max(a, b));
      
    const newId = maxId + 1;
    const newKey = newId + 1;

    const newTable = { ...table };
    newTable[newId] = { key: newKey, content: { id: newId, title: 'New Task' } };

    setTable(newTable);
    console.log(table); // デバッグ用
  };

  const deleteToDoCard = (id: number) => {
    const newTable = { ...table };
    delete newTable[id];

    setTable(newTable);
    console.log(table); // デバッグ用
  };

  return (
    <>
      <div className='todo-grid-wrapper'>
        {Object.values(table).map(({key, content}) => (
          <ToDoCard
            key={key}
            id={content.id}
            title={content.title}
            updateTable={updateTable}
            deleteToDoCard={deleteToDoCard}
          />
        ))}
        <AddToDoCardButton addToDoCard={addToDoCard}/>
      </div>
    </>
  );
}

interface ToDoCardProps {
  id: number;
  title: string;
  updateTable: (id: number, title: string) => void;
  deleteToDoCard: (id: number) => void;
};

const ToDoCard: React.FC<ToDoCardProps> = ({ id, title, updateTable, deleteToDoCard }) => {
  // key は ToDoList が要素を管理するために React が使用する値なのでここでは受け取らない
  const [isEditing, setIsEditing] = useState(false);
  const [inputValue, setInputValue] = useState(title); // 入力値を状態管理

  const handleTitleClick = () => {
    setIsEditing(true); // クリックされたときに入力状態にする
  };
  
  const handleTitleBlur = () => {
    setIsEditing(false); // フォーカスが外れたときに再度 <div> に戻る

    if (inputValue == '') {
      // inputValue が空だと編集できなくなるので
      // 空の場合は埋め草を入れておく
      title = 'New Task';
      setInputValue(title);
      updateTable(id, title);
    } else {
      updateTable(id, inputValue);
    }
  };

  const handleDeleteButtonClick = () => {
    deleteToDoCard(id);
  };

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value); // 入力値をリアルタイムに状態に反映
  };

  const viewer = (
    <>
      <div className='todo-grid-item'>
        <button className='todo-grid-item-button' onClick={handleDeleteButtonClick}>-</button>
        <span className='todo-grid-item-title' onClick={handleTitleClick}>{inputValue}</span>
      </div>
    </>
  );

  const editor = (
    <>
      <div className='todo-grid-item'>
        <input
          className='todo-grid-item-input'
          type='text'
          value={inputValue}
          onChange={handleInputChange}
          autoFocus
          onBlur={handleTitleBlur}
        />
      </div>
    </>
  );

  return (
    <>
      { isEditing ? editor : viewer }
    </>
  );
}

interface AddToDoCardButtonProps {
  addToDoCard: () => void;
};

const AddToDoCardButton: React.FC<AddToDoCardButtonProps> = ({addToDoCard}) => {
  const handleOnClick = () => {
    addToDoCard();
  };

  return (
    <>
      <div className='todo-grid-item' onClick={handleOnClick}>
        <button className='todo-grid-item-button'>+</button>
      </div>
    </>
  )
}

function App() {
  return (
    <>
      <h1>Rabit ToDo</h1>
      <ToDoList/>
    </>
  );
}

export default App

この時点で ToDo リストの状態はサーバー側に保存されるようになった。サーバーを落とさない限りは一度ブラウザを閉じても前回の ToDo リストが復元されるようになったはずである。

3.4. データベース

ここまででおもちゃとしては十分だが、練習のためにバックエンド側で受け取った ToDo リストをデータベースに保存する。データベースをディスクに永続化することでサーバーを落としても再起動時に復元することができ、これをもって一応「実際に使える」水準に達する。

3.5. データベースの初期化と永続化の設定を行う

データベースに ToDo リストを保存するためのテーブルを作成する初期設定を行う。また、データベースに対する変更が永続化されるように、Docker コンテナにホスト側のボリュームをマウントする。

必要なファイルを作成するために以下のコマンドを実行する。

cd rabit-db
mkdir initdb.d
mkdir postgresql_data
mkdir scripts
touch initdb.d/init.sh
chmod +x initdb.d/init.sh
touch scripts/create_table.sql

ディレクトリ構成は以下のようになる。

rabit-db
├── Dockerfile
├── docker-compose.yml
├── initdb.d
│   └── init.sh
├── postgresql_data
└── scripts
    └── create_table.sql

各ファイルを以下のように変更する。

Dockerfile

コンテナ作成時にスクリプトが実行されるようにスクリプトファイルを所定のディレクトリにコピーする。

Dockerfile
FROM postgres:16.4

COPY initdb.d /docker-entrypoint-initdb.d
COPY scripts /scripts
docker-compose.yml

データを保存するボリュームをマウントする。POSTGRES_ USER などの環境変数はデフォルトから変更したい場合に設定する。

docker-compose.yml
services:
  db:
    container_name: rabit_postgres_db
    restart: always
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      POSTGRES_USER: rabit
      POSTGRES_DB: rabit_db
      POSTGRES_PASSWORD: password
    ports:
      - '5432:5432'
    volumes:
      - ./postgresql_data:/var/lib/postgresql/data
initdb.d/init.sh

create_table.sql を実行するようにスクリプトを書いておく。initdb.d の中にあるスクリプトは辞書順で実行されてしまうので、複数のスクリプトを実行したい場合はこのように init.sh をひとつだけ作成し、他のディレクトリにあるスクリプトを実行するように設定しておくとよいのではと思う。

initdb.d/init.sh
#!/bin/bash
set -e

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
    -f /scripts/create_table.sql
scripts/create_table.sql

ToDo リストを保存するためのテーブルを作成する。

scripts/create_table.sql
CREATE TABLE todos (
    id              int,
    react_key       int,
    title           varchar(256)
);

以下のコマンドでデータベースを起動する。

cd rabit-db
docker compose up -d

注意点としては、init.sh は永続化されたボリュームである postgresql_data の中身が空である場合にのみ実行される。一度コンテナを起動するとデータベースが初期化されて postgresql_data の中に複数のファイルが書き込まれてしまうので、再度 init.sh を実行したければ、コンテナを削除した上で postgresql_data の中身も削除しなければならない。

うまく設定できていれば psql でデータベースに接続後、\dt でテーブルが作成されていることを確認できる。

$ docker container exec -it 5b105 psql -d rabit_db -U rabit
psql (16.4 (Debian 16.4-1.pgdg120+1))
Type "help" for help.

rabit_db=# \dt
       List of relations
 Schema | Name  | Type  | Owner 
--------+-------+-------+-------
 public | todos | table | rabit
(1 row)

3.6. バックエンドとデータベースを結合する

これがいよいよ最後の工程となる。受け取ったリクエストの内容をデータベースに書き込むように golang サーバーのコードを編集する。

まずは golang と PostgreSQL との接続テストを以下のように書き直す。

rabit-server/postgres_test.go
rabit-server/postgres_test.go
package main

import (
	"database/sql"
	"log"
	"os"
	"testing"

	_ "github.com/lib/pq"
)

var db *sql.DB

// テスト前にPostgreSQLに接続し、Pingをテスト
func TestMain(m *testing.M) {
	// PostgreSQLの接続情報
	connStr := "user=rabit password=password dbname=rabit_db sslmode=disable"

	var err error
	db, err = sql.Open("postgres", connStr)
	if err != nil {
		log.Fatalf("データベース接続エラー: %v", err)
	}

	// deferでDB接続をクローズ
	defer db.Close()

	// 接続確認
	err = db.Ping()
	if err != nil {
		log.Fatalf("データベースに接続できませんでした: %v", err)
	}

	// テストの実行
	code := m.Run()

	// 終了コードを返す
	os.Exit(code)
}

// Todoの構造体
type TodoRecord struct {
	ID       int
	ReactKey int
	Title    string
}

func TestTodoOperations(t *testing.T) {
	// レコードを挿入
	_, err := db.Exec("INSERT INTO todos (id, react_key, title) VALUES (1, 100, 'First Todo'), (2, 200, 'Second Todo')")
	if err != nil {
		t.Fatalf("レコード挿入エラー: %v", err)
	}
	t.Log("レコードを挿入しました")

	// 挿入したレコードを取得
	rows, err := db.Query("SELECT id, react_key, title FROM todos")
	if err != nil {
		t.Fatalf("クエリ実行エラー: %v", err)
	}
	defer rows.Close()

	var todos []TodoRecord
	for rows.Next() {
		var todo TodoRecord
		if err := rows.Scan(&todo.ID, &todo.ReactKey, &todo.Title); err != nil {
			t.Fatalf("レコード取得エラー: %v", err)
		}
		todos = append(todos, todo)
	}
	if err = rows.Err(); err != nil {
		t.Fatalf("クエリエラー: %v", err)
	}

	// 結果の出力と確認
	if len(todos) == 0 {
		t.Fatalf("レコードが存在しません")
	}
	t.Logf("取得したレコード数: %d", len(todos))
	for _, todo := range todos {
		t.Logf("ID: %d, ReactKey: %d, Title: %s", todo.ID, todo.ReactKey, todo.Title)
	}

	// 挿入したレコードを削除
	_, err = db.Exec("DELETE FROM todos WHERE id IN (1, 2)")
	if err != nil {
		t.Fatalf("レコード削除エラー: %v", err)
	}
	t.Log("挿入したレコードを削除しました")
}

テストを実行して OK となることを確かめる。

cd rabit-server
go test

あとはクライアントからのリクエスト時にテーブルを書き換えるようにサーバーのコードを編集する。

rabit-server/server.go
rabit-server/server.go
package main

import (
	"database/sql"
	"fmt"
	"net/http"
	"strings"

	"github.com/labstack/echo/v4"
	_ "github.com/lib/pq"
)

type Todo struct {
	Key     int `json:"key"`
	Content struct {
		ID    int    `json:"id"`
		Title string `json:"title"`
	} `json:"content"`
}

type TodoRecord struct {
	ID    int
	Key   int
	Title string
}

// データベース保存用の構造体に変換する
func (t *Todo) ToTodoRecord() TodoRecord {
	return TodoRecord{
		ID:    t.Content.ID,
		Key:   t.Key,
		Title: t.Content.Title,
	}
}

func (r *TodoRecord) ToTodo() Todo {
	return Todo{
		Key: r.Key,
		Content: struct {
			ID    int    `json:"id"`
			Title string `json:"title"`
		}{
			ID:    r.ID,
			Title: r.Title,
		},
	}
}

func (r *TodoRecord) ToQueryString() string {
	return fmt.Sprintf("(%d, %d, '%s')", r.ID, r.Key, r.Title)
}

// データベース
var db *sql.DB

func main() {
	e := echo.New()

	// フロントエンドの提供
	e.File("/", "dist/index.html")
	e.Static("/assets", "dist/assets")

	// ToDo のリストを取得する
	e.GET("/api/todos", func(c echo.Context) error {
		var err error

		// データベースに接続
		connStr := "user=rabit password=password dbname=rabit_db sslmode=disable"
		db, err = sql.Open("postgres", connStr)
		if err != nil {
			fmt.Println("データベース接続エラー: %v", err)
			return c.JSON(http.StatusInternalServerError, echo.Map{
				"message": "Internal Server Error",
			})
		}

		// defer でDB接続をクローズ
		defer db.Close()

		// 挿入したレコードを取得
		query := "SELECT id, react_key, title FROM todos"
		rows, err := db.Query(query)
		if err != nil {
			fmt.Println("クエリ実行エラー: %v", err)
			return c.JSON(http.StatusInternalServerError, echo.Map{
				"message": "Internal Server Error",
			})
		}

		// defer で読み取りをクローズ
		defer rows.Close()

		// レコードを構造体に格納する
		var todos []Todo = []Todo{}  // テーブルが空のとき nil ではなく空配列を返すため初期化しておく
		for rows.Next() {
			var record TodoRecord
			if err := rows.Scan(&record.ID, &record.Key, &record.Title); err != nil {
				fmt.Println("レコード取得エラー: %v", err)
				return c.JSON(http.StatusInternalServerError, echo.Map{
					"message": "Internal Server Error",
				})
			}
			todos = append(todos, record.ToTodo())
		}

		if err = rows.Err(); err != nil {
			fmt.Println("クエリエラー: %v", err)
			return c.JSON(http.StatusInternalServerError, echo.Map{
				"message": "Internal Server Error",
			})
		}

		fmt.Println("正常にデータを取得しました")

		return c.JSON(http.StatusOK, todos)
	})

	// ToDo のリストを更新する
	e.PUT("/api/todos", func(c echo.Context) error {
		var err error
		var todos []Todo = []Todo{} 
		var todoQueries []string

		// 受け取った JSON を構造体にバインドする
		if err := c.Bind(&todos); err != nil {
			return c.JSON(http.StatusBadRequest, echo.Map{
				"message": "Invalid JSON",
			})
		}

		// 受け取ったデータをクエリに変換する
		for _, todo := range todos {
			record := todo.ToTodoRecord()
			todoQueries = append(todoQueries, record.ToQueryString())
		}

		records := strings.Join(todoQueries, ", ")
		query := fmt.Sprintf("INSERT INTO todos (id, react_key, title) VALUES %s", records)

		// データベースに接続
		connStr := "user=rabit password=password dbname=rabit_db sslmode=disable"

		db, err = sql.Open("postgres", connStr)
		if err != nil {
			fmt.Println("データベース接続エラー: %v", err)
			return c.JSON(http.StatusInternalServerError, echo.Map{
				"message": "Internal Server Error",
			})
		}

		// deferでDB接続をクローズ
		defer db.Close()

		// テーブル内の全レコードを削除
		_, err = db.Exec("DELETE FROM todos")
		if err != nil {
			fmt.Println("レコード削除エラー: %v", err)
			return c.JSON(http.StatusInternalServerError, echo.Map{
				"message": "Internal Server Error",
			})
		}

		// 受け取った Todo リストが空なら挿入ここで終了
		if len(todos) == 0 {
			fmt.Println("挿入するレコードがありません")
			return c.JSON(http.StatusOK, todos)
		}

		// レコードを挿入
		_, err = db.Exec(query)
		if err != nil {
			fmt.Println("レコード挿入エラー: %v", err)
			return c.JSON(http.StatusInternalServerError, echo.Map{
				"message": "Internal Server Error",
			})
		}

		fmt.Println("レコードを挿入しました")

		// 更新された ToDo リストを返す
		return c.JSON(http.StatusOK, todos)
	})

	// id が一致する ToDo アイテムを削除する
	e.DELETE("/api/todos", func(c echo.Context) error {
		var err error
		var todosToDelete []Todo
		var todoIDToDelete []int

		// 受け取った JSON を構造体にバインドする
		if err = c.Bind(&todosToDelete); err != nil {
			return c.JSON(http.StatusBadRequest, echo.Map{
				"message": "Invalid JSON",
			})
		}

		// 一致する ID の Todo を削除
		for _, todoToDelete := range todosToDelete {
			todoIDToDelete = append(todoIDToDelete, todoToDelete.Content.ID)
		}
    	
		// todoIDToDelete をカンマ区切りの文字列に変換
		var idList []string
		for _, id := range todoIDToDelete {
			idList = append(idList, fmt.Sprintf("%d", id))
		}
		idString := strings.Join(idList, ", ")

		// レコードを削除
		query := fmt.Sprintf("DELETE FROM todos WHERE id IN (%s)", idString)

		connStr := "user=rabit password=password dbname=rabit_db sslmode=disable"

		db, err = sql.Open("postgres", connStr)
		if err != nil {
			fmt.Println("データベース接続エラー: %v", err)
			return c.JSON(http.StatusInternalServerError, echo.Map{
				"message": "Internal Server Error",
			})
		}

		// deferでDB接続をクローズ
		defer db.Close()

		// 挿入したレコードを削除
		_, err = db.Exec(query)
		if err != nil {
			fmt.Println("レコード削除エラー: %v", err)
			return c.JSON(http.StatusInternalServerError, echo.Map{
				"message": "Internal Server Error",
			})
		}

		// todoIDToDelete の中身を出力 (標準出力)
    	fmt.Println("削除対象のID:", query)
		
		// 削除後
		return c.JSON(http.StatusOK, echo.Map{
			"message": "Success",
		})
	})

	e.Logger.Fatal(e.Start(":1323"))
}

あとはリビルドして起動するだけ。

cd rabit
make build_app
make run_app

これでアプリを一度閉じても閉じる前の内容が表示されるようになった。

おしまい

思ったよりもずいぶんと長い記事になりました。ここまで読んでくださって本当にありがとうございました。

後半のコーディングはほとんど ChatGPT に任せていたのですが、本当に優秀ですね。依然として ChatGPT ができないことはこちらでカバーする必要があるのですが、

  1. こちらが方針やプログラムの大枠を示す
  2. ChatGPT が細かいコーディングを行う
  3. こちらでファクトチェックしたりエラーなどの細かい不整合を取り除く

という役割分担で、ここまでできてしまいました。ひとりで全部調べていたら 10 倍くらいの時間がかかったと思います。めちゃくちゃ優秀な部下という感覚でガンガンこき使いました[14]

勉強の仕方やプログラミングの仕方はもはや変わってしまった[15]と思うべきで、自分が専門とする領域以外は ChatGPT に書かせて、人間はその最終チェックを行い何かが起こったときの責任を取る方向性に舵を切るほうがうまくいきそうです。

今回の記事を最後に、しばらくは大型の記事の執筆は控える予定です。少し他にやりたいことができたので、また別の形で引き続きコミュニティに貢献していけたらと思います。

それではみなさんご機嫌よう。

脚注
  1. そうは言いつつ何度か手を出したことはあるが、Next.js や Remix などのフレームワークはバックエンドにもこれらのフレームワークを用いることを想定している節があるので、クライアントとサーバーを疎結合にしたい私としては「なんか違う」と感じる部分があった。アプリケーションのすべてを Next.js で構成する前提であればよいかもしれないが、勉強する上ではバックエンド側がどのような構成であっても対応できるようにフロントエンド側の技術スタックを構成するほうがよいと判断した。 ↩︎

  2. Elm を勉強していた当時、もっとも懸念していたのは Elm の開発者がワンマンで開発しており、開発の権限をコミュニティに渡さなかったことである。つまりその開発者がやめるか事故で死んだら終わりという開発体制になっていて、実際に 2019 年のリリースを最後に開発が止まっている。2016 年頃は次々と現れる AltJS(もはや死語?)のどれが生き残るか読めない部分があったが、いまから勉強するのであればその戦争を勝ち残った TypeScript を勉強するのがもっとも無難と言える。 ↩︎

  3. golang はネットワーク関連という変化のスピードが遅いかつ簡単に壊れたら困る領域に特化したプログラミング言語であることから後方互換性が他の言語よりも高めで、数年前の知識が通用する部分も多く、勉強にはもってこいの言語である。golang のフレームワークはどれもシンプルなので割とどれでもよい。Gin と Echo のどちらにするか悩んだ。後々のことを考えると example の豊富な Gin のほうが苦労が少なそうではあるが、コンテキストの挙動に若干クセがあって悩まされたし、その挙動を追跡するために深入りするにはコード量が多すぎてけっこう辛かった記憶があるので、今回は小さめの Echo を試してみることにした。データベースに関しては、私が普段勉強しているのは Apache Cassandra だが、この記事の価値を高めるのであれば無難な構成にしたほうがよいだろうと判断して PostgreSQL を選択した。 ↩︎

  4. Prisma などの ORM も適宜活用するとよい。 ↩︎

  5. Python での ORM は SQLAlchemy など。ちなみに golang でよく使われる gorm はちょっとしたミスでテーブルの全データを消し飛ばす挙動が一時期話題になったこともあり、今回の記事では使用を避けている。 ↩︎

  6. 用途別に複数台のノートPCやクラウド上のサーバーを持っているが、それらにまたがるように構築した開発環境では生産性が上がらないと最近思っている。開発環境をあまり複雑にすると1日1時間くらいしか勉強時間が確保できないときにどこに何が置いてあるかを思い出すだけで 30 分くらいかかってしまうし、「あー、思い出すだけで 30 分か……」という心理的な障壁は勉強に取り掛かることすら妨げる。今回の記事は開発環境を Macbook Air に集約させる実験も兼ねて執筆している。 ↩︎

  7. 仮想化の利点は大きく分けて2つある。1つ目は既存の環境を破壊しないこと。2つ目は他の環境に簡単に移植できること。Docker で仮想化することも考えたが、macOS における Docker の挙動は Linux と微妙に異なるので移植に際するメリットはない。したがって既存の環境を破壊しないだけの仮想化ができるツールで十分である。 ↩︎

  8. 当初の予定では CRUD の4つを別々に実装する予定であったが、実際に実装してみると useEffect の挿入位置や記事にするためのコード量を考えても ToDo リストの更新時にリストの全体を送信することで Create, Update, Delete は同一の機能として実現するのが最適であろうという判断になった。サーバー側には DELETE の API エンドポイントを作成するが、フロントエンド側から使用することはない。 ↩︎

  9. MDN のドキュメントには「与えられたプロパティの値が妥当ではなかったとき、その宣言は妥当ではないと見なされ、CSS エンジンから完全に無視されます」と書かれていた。つまり自分が間違っていても気づきようがない上に、運よく気づいてもどう修正すべきかのヒントは得られないということであり、この時点で私は真面目に勉強して自分で書こうという当初の決意を一欠片も残さず放棄した。もしこの仕様策定に関わった者がいれば石打ちの刑に処するので遠慮なく挙手してほしい。 ↩︎

  10. そのサービスの RESTful 度合いを判断する『Richardson の成熟度モデル』によれば、すべてのリソース操作を POST メソッドで行うところが Level 1 で、それぞれの HTTP 動詞の意味に合わせて適切なメソッドを選択することが Level 2 となる条件である。 ↩︎

  11. 既に気づいている読者もいるかもしれないが、この記事はぶっつけ本番の行き当たりばったりで書いている。反省点としてはフロント側でサーバーと通信するロジックまでそれなりに作り込んでからバックエンド側を作り込んだほうがよいのかもしれない。 ↩︎

  12. この辺りの論争は詳しく説明されても正直理解できないと思うので、あまり巻き込まれたくはないです。公式ドキュメントに反しているとか、明らかなバグに繋がるという場合にのみご指摘いただけますと幸いです。 ↩︎

  13. 実際に使おうとしてみるとトリガーされるタイミングが state に結びついているせいで、useEffect の中から state を更新しようとすると容易に無限ループを引き起こし、本来の目的である「外部システムとの同期」にすら使いづらい。あまり直感的とは言い難い useEffect の挙動にロジックが縛られてしまうので、ここが React のちょっと嫌いポイントになりそう。experimental の use が使えるようになれば幾分マシになるかも。 ↩︎

  14. 虚言癖については人間も大して正確ではないので人間の部下や同僚の発言に対しても結局ファクトチェックしますしね。人間のハルシネーション度合いも測定したら相当だと思います。 ↩︎

  15. ChatGPT にはあれができない、これができないという欠点を根拠にプログラマの優位性や必要性を訴える人がいます。確かに現時点ではそうかもしれませんが、「できるようになるのは時間の問題である」というのが私の見立てです。AI は将棋でプロ棋士に勝つことができない、囲碁でプロ棋士に勝つことができないというのが 2010 年頃の予想でしたが、わずか 10 年ほどで覆されました。AI を賢くするための手がかりがまったく掴めていない時代だったならともかく、創意工夫の余地が無数にある現状を踏まえれば AI は遠からず人間の情報処理能力のほとんどを代替可能な存在になるでしょう。 ↩︎

Discussion

t-coolt-cool

すごく参考になりました!
一点、レポジトリのリンクが切れているようです🙏

Josh NobusJosh Nobus

ご指摘ありがとうございます。private のままでした。public に変更いたしました!