React版 YelpCampデモ

今回はReactでYelpCampを実装します。YelpCampとはUdemyさんの以下の講座の中で紹介されているWEBアプリで、オリジナル版はNode.jsとExpressで構築されています。基本的なWEBアプリに必要と思われるほとんどの機能を網羅しているので、他のフレームワークでもこのアプリを実装できればかなり効率よく学習できると思われます。なお、日本人講師の方から移植版の掲載許可をいただいておりますので、ご厚意に甘えて移植シリーズ記事を作成しています。(こちらのコースはNode.js主体の講座ですが、最近React関連のセクションが多数追加されましたので、JavaScriptからReactまで一気に学習したい場合はお得かも知れません。)

Udemy

【世界で90万人が受講】Web Developer Bootcamp(日本語版)

Reactを使用すると、以前の記事でご紹介したBlazorと同様に単一ページアプリケーション(SPA)を構築できます。Blazorに比べると周辺のフレームワークやライブラリーが充実しているため、かなり柔軟な制御が可能な様です。

今回の記事を作成するにあたり、以下の講座で勉強させていただきました。非常に内容が深く、Reactの各機能について詳細に解説されています。特に、Hooks(後ほどご説明する関数コンポーネント内で、状態を管理するための関数群)の説明や、Redux(主にアプリケーション全体の状態を一元管理するライブラリ)、Next.js(Reactベースでサーバーサイドレンダリングや静的サイト生成を実現するフレームワーク)といった周辺技術についても詳しく取り上げられており、大変勉強になりました。

Udemy

React(v18)完全入門ガイド|Hooks、Next14、Redux、TypeScript

また、Reactはフロントエンドのフレームワークのため、動作させるにはバックエンドとなるWEBサーバーが必要となります。Node.js等を使用すれば両方JavaScriptで統一できるので開発効率がいいですが、今回はGoでバックエンドの処理を実装します。Node.jsより高速に動作する様です。

今回の記事を作成するにあたり、以下の講座で勉強させていただきました。講義時間は短めですが、的確に要点を押さえて解説されているため、効率よく学習できました。特に「トライツリー」と呼ばれる、ルーティング用のコードを劇的に省略できる木構造を用いたルーティング方法や、どの様にGo言語でミドルウェアを実装すればよいのか、などのトピックが大変勉強になりました。

Udemy

ゼロからGo言語でウェブフレームワークを作ります

追加でChatGPTにも色々教えてもらいました。もう手放せません。

目次

アプリケーションのひな型を作成します

この記事ではMAMPのホームディレクトリ(C:\MAMP\htdocs)に、「YelpCampReact」というアプリケーションを作成する前提でご説明しています。ご自身の環境に合わせて適宜ディレクトリ名を読み替えてください。基本的にこちらの記事でご紹介したアプリの移植作業となります。最終的にWEBサーバー(Apache2)のリバースプロキシで動作させることを目指します。

今回の実装環境は以下の様になっています。

  • MAMP・・・Windows版 5.0.6
  • React・・・バージョン 19.1.0
  • Next.js・・・バージョン 15.4.5
  • Go・・・バージョン 1.24.3

Reactをインストールするには、npmというパッケージマネージャー(Node.jsに含まれています)が必要なため、こちらのサイト様を参考にしてNode.jsをインストールして下さい。Goをインストールするにはこちらの記事をご参照ください。

エクスプローラ等を起動して「C:\MAMP\htdocs\YelpCampReact」というフォルダーを作成してVSCodeで開きます。大まかですが以下の様なフォルダ構成にしたいので、「backend」と「frontend」というフォルダを作成しておきます。

YelpCampReact
├── backend/        → Goのソースコードフォルダ
│   ├── go.mod
│   └── main.go
└── frontend/       → Reactのソースコードフォルダ
    ├── public/
    ├── src/
    └── package.json
・フロントエンドのひな型を作成します

「Ctrl + @」と入力してターミナル画面を表示します。以下の様に入力してReactアプリのひな型を作成します。今回はNext.jsも使用するので、ひな型の作成は「npm」コマンドではなく「npx」コマンドを使用しています。(フォルダ名は「cd 」まで入力して、左側のエクスプローラービューから「frontend」フォルダをターミナル画面にドラッグ&ドロップすると入力の手間が省けます。)

cd c:\MAMP\htdocs\YelpCampReact\frontend
npx create-next-app@latest .

作成コマンドを入力すると色々聞かれますので、2行目から9行目までの質問に対して以下の様に答えます。実際には「No / Yes」という風に表示されますので、左右のキーで選択します。

PS C:\MAMP\htdocs\YelpCampReact\frontend> npx create-next-app@latest .
√ Would you like to use TypeScript? ... No
√ Would you like to use ESLint? ... No
√ Would you like to use Tailwind CSS? ... No
√ Would you like your code inside a `src/` directory? ... Yes
√ Would you like to use App Router? (recommended) ... Yes
√ Would you like to use Turbopack for `next dev`? ... No
√ Would you like to customize the import alias (`@/*` by default)? ... Yes
√ What import alias would you like configured? ... <何も入力せずにリターンキー>
Creating a new Next.js app in C:\MAMP\htdocs\YelpCampReact\frontend.

Using npm.

Initializing project with template: app 


Installing dependencies:
- react
- react-dom
- next


added 25 packages, and audited 26 packages in 17s

5 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
Success! Created frontend at C:\MAMP\htdocs\YelpCampReact\frontend

PS C:\MAMP\htdocs\YelpCampReact\frontend> 

以上でフロントエンド(React + Next.js)側の設定は終了です。コンソール画面で「npm run dev」と入力すると、開発用のサーバーが起動します。「http://localhost:3000」の部分をCtrlキーを押しながらクリックするとブラウザーが起動してトップページが表示されます。停止する場合はターミナル画面で「Ctrl + c」と入力して「y」キーを押します。

PS C:\MAMP\htdocs\YelpCampReact\frontend> npm run dev

> frontend@0.1.0 dev
> next dev

   ▲ Next.js 15.4.5
   - Local:        http://localhost:3000
   - Network:      http://192.168.11.19:3000

 ✓ Starting...
 ✓ Ready in 1544ms

現在、 Next.js は Vite には正式に対応していない様なので、開発時はNext.jsの専用サーバーが起動する様です。

起動するポート番号を変更したい場合は、「frontend\package.json」ファイルを開いて以下の様に設定します。6行目は開発時のポート番号で、8行目はリリース(デプロイ)時のポート番号になっています。

{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev -p 3002",
    "build": "next build",
    "start": "next start -p 3002",
    "lint": "next lint"
  },
  "dependencies": {
    "react": "19.1.0",
    "react-dom": "19.1.0",
    "next": "15.4.5"
  }
}
・バックエンドのひな型を作成します

次にバックエンド(Go)側を設定します。VSCodeのターミナル画面に以下の様に入力して、ソースフォルダを初期化します。初期化が完了すると、フォルダ内に「go.mod」というファイルが作成されます。Goのバージョン1.11以降からは、このファイル(go.mod)を作成しておくと、以前は必要だった「GOPATH」という環境変数の設定が必要なくなる様です。(設定していても動作します。)

cd c:\MAMP\htdocs\YelpCampReact\backend
go mod init yelpcampreact

以上でバックエンド(Go)側の設定も終了です。動作確認のため「main.go」というファイルを作成して以下の内容を書き込みます。

package main
import "fmt"
func main() {
	fmt.Println("Hello!")
}

ターミナル画面に「go run main.go」と入力して、画面に「Hello!」と表示されれば正常に動作しています。

PS C:\MAMP\htdocs\YelpCampReact\backend> go run main.go
Hello!
PS C:\MAMP\htdocs\YelpCampReact\backend>
デバッグ環境を整えます

アプリケーションフォルダ内に「.vscode\launch.json」というファイルを作成して以下の内容を書き込みます。Reactの方のデバッガー名は「Frontend: Next.js」、Goの方は「Backend: Go」という名称になっています。「Debug All」を選択すると両方のデバッガーが同時に起動します。

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Frontend: Next.js",
            "type": "node",
            "request": "launch",
            "runtimeExecutable": "npm",
            "runtimeArgs": [
                "run",
                "dev"
            ],
            "cwd": "${workspaceFolder}/frontend",
            "console": "integratedTerminal",
        },
        {
            "name": "Backend: Go",
            "type": "go",
            "request": "launch",
            "mode": "auto",
            "program": "${workspaceFolder}/backend",
            "cwd": "${workspaceFolder}/backend"
        }
    ],
    "compounds": [
        {
            "name": "Debug All",
            "configurations": [
                "Frontend: Next.js",
                "Backend: Go"
            ]
        }
    ]
}

Reactのデバッガーを起動すると、以下の様なデバッグ機能が有効になった開発用サーバーが起動します。

PS C:\MAMP\htdocs\YelpCampReact\frontend>  C:\; cd 'C:\MAMP\htdocs\YelpCampReact/frontend'; ${env:NODE_OPTIONS}=' --require "c:\/Users/admin/AppData/Local/Programs/Microsoft VS Code/resources/app/extensions/ms-vscode.js-debug/src/bootloader.js"  --inspect-publish-uid=http'; ${env:VSCODE_INSPECTOR_OPTIONS}=':::{"inspectorIpc":".pipenode-cdp.27540-af53b823-11.sock","deferredMode":false,"waitForDebugger":"","execPath":"C:\Program Filesnodejsnode.exe","onlyEntrypoint":false,"autoAttachMode":"always","fileCallback":"R:tempnode-debug-callback-9455e78b341348a0"}'; & 'C:\Program Filesnodejsnpm.cmd' 'run' 'dev'
Debugger attached.

> frontend@0.1.0 dev
> next dev -p 3002

Debugger attached.
Debugger attached.
   ▲ Next.js 15.4.5
   - Local:        http://localhost:3002
   - Network:      http://192.168.11.19:3002

 ✓ Starting...
 ✓ Ready in 2s

「frontend\src\app\page.js」というファイルの5行目辺りにブレークポイントを設定して、ターミナル画面の「http://localhost:3000」の部分をCtrlキーを押しながらクリックするとブラウザーが起動して、ブレークポイントで停止します。(return文内の「jsx」と呼ばれる部分で有効です。後ほどご説明する「フック関数」内では停止しない様です。フック関数外にあるコードでは停止します。)

Goの場合は、先ほど作成した「backend\main.go」ファイルの6行目辺りにブレークポイントを設定して、「Backend: Go」デバッガーを実行するとブレークポイントで停止します。

VSCodeは同時に複数のデバッガーを起動できるので、実行とデバッグのドロップダウンメニューから「Debug All」を選択して実行すると、ReactとGoのデバッグが同時に行えます。(実際にはもう1つVSCodeを起動して、個別にデバッガーを起動した方が開発しやすいかもしれません。)

ビューを作成します
・メインページを作成します

最初にメインページから作成したいので、起点となるファイルを探します。純粋なReactの場合は以下の様な構成になっている様です。まず「index.html」から始まり、このファイルで「main.jsx」スクリプトファイルが読み込まれます。さらにこのファイルから「App.jsx」コンポーネントが呼び出されて最初のページが表示される様です。

【Reactのみ】

YelpCampReact/
└─ frontend/
    ├─ src/
    │    ├─ App.jsx
    │    └─ main.jsx
    └─ index.html

React + Next.js + App Routerという今回の構成では、「frontend\src\app\page.js」というファイルがメインのページになる様です。index.htmlという起点になるファイルは存在せず、Next.jsが内部的にhtmlファイルを生成する様です。

【React + Next.js + App Router】

YelpCampReact/
└─ frontend/
    └─ src/
         └─ app/
             └─ page.js

Reactのコンポーネントは以下の様になっていて、実体はHTML風のオブジェクト(JSXと呼ばれています)を返す関数定義になっています。これらの関数は「関数コンポーネント」と呼ばれている様です。1つのファイル内に複数のコンポーネントを定義できますが、部品として使用する場合は、単独のコンポーネントにしておいた方がメンテナンしやすいかもしれません。(複数の関数を定義してライブラリー的にも使用可能です。)

これ以外にも「クラスコンポーネント」というコンポーネントがある様ですが、現在は関数コンポーネントの使用が推奨されている様です。コンポーネントファイルの拡張子は「.js」でも「.jsx」のどちらでもかまわない様です。(開発言語にTypeScriptを選択した場合は、「.ts」または「.tsx」になります。)

export default function Home() {
  return (
    <h1>メインページ</h1>
  );
}

最初に表示するファイルが分かりましたので、まずはメイン画面から作成してきます。関数の戻り値を直接HTML風に書けるのでHTML部分の移植は簡単ですが、以下の様な制約があります。

  • 単一のHTML(JSX)しか返せない (配列にすれば返せるが、keyが必要)
  • すべてのエレメント(タグ)は「/>」で閉じる必要がある

以下の様な関数はエラーになります。

export default function Home() {
  return (
    <h1>メインページ</h1>
    <h2>コンテンツ</h2>
  );
}

その場合は<div>などで囲むか、フラグメント(Fragment)と呼ばれるタグで囲むといい様です。フラグメントタグは実際のHTMLには展開されない様です。省略形と呼ばれる記述方法は何もインポートせずに使用できます。

export default function Home() {
    return (
        <>
            <h1>メインページ</h1>
            <h2>コンテンツ</h2>
        </>
    );
}

コントロールを再描画するなどの理由で、key属性などを付けたい場合は省略できない様です。

import { Fragment} from 'react';

export default function Home() {
    return (
        <Fragment key={guid}>
            <h1>メインページ</h1>
            <h2>コンテンツ</h2>
        </Fragment>
    );
}

また、以下の様にタグの最後が「/>」で閉じていないとエラーになる様です。

function PublicImage() {
  return <img src="/images/logo.png" alt="ロゴ">; // エラー
}
↓
function PublicImage() {
  return <img src="/images/logo.png" alt="ロゴ" />; // OK
}

以上を踏まえてメインページを移植しました。(何かエラーや警告がある場合は、右下または左下に赤いボタンが表示され、クリックすると詳細が表示される様です。)

ページで読み込んでいるスクリプトやスタイルシートは、「frontend\public」フォルダ内に「js」と「css」というフォルダを作成して追加しました。

YelpCampReact/
├── frontend/                   # React + Next.js(App Router 使用)
│   ├── public/                 # 静的ファイル(画像など)
│   │   ├── js/
│   │   │   └── bootstrap.bundle.min.js
│   │   └── css/
│   │       └── home.css
│   ├── src/
│   │   └── app/                # App Router エントリ
│   │       └── page.js         # "/" ルートページ
│   └── package.json
└── backend/                    # Go (Golang) バックエンド
    ├── main.go
    └── go.mod
・サブページを作成します

メインページからリンクされているサブページを作成します。YelpCampはメインページのみレイアウトがなく、サブページはすべて同じヘッダーとフッターを持ったレイアウトになっています。Next.js + App Router 構成で複数のページに同じレイアウトを適用するには以下の様にする様です。

src/
├─ app/
│   ├─ page.js            ← メインページ(レイアウトなし)
│   ├─ layout.js          ← 全体レイアウト 
│   ├─ campgrounds/
│   │   ├─ edit/
│   │   │   ├─ layout.js  ← 共通レイアウト使用
│   │   │   └─ page.js
│   │   ├─ new/
│   │   │   ├─ layout.js  ← 共通レイアウト使用
│   │   │   └─ page.js
│   │   ├─ show/
│   │   │   ├─ layout.js  ← 共通レイアウト使用
│   │   │   └─ page.js
│   │   └─ page.js
│   ├─ login
│   │   ├─ layout.js  ← 共通レイアウト使用
│   │   └─ page.js
│   └─ register
│       ├─ layout.js  ← 共通レイアウト使用
│       └─ page.js
└─ components/
     └─ CommonLayout.jsx  ← 共通レイアウト

23行目の「CommonLayout.jsx」ファイルで共通のレイアウトを定義します。(拡張子は「.js」でもいい様です。)

export default function CommonLayout({ children }) {
  return (
    <>
      <header>共通ヘッダー</header>
      <main>{children}</main>
      <footer>共通フッター</footer>
    </>
  );
}

それ以外の「layout.js」ファイルは以下の様に定義します。(App Routerではレイアウト用のファイル名は「layout」固定になっている様です。) 関数名は特に何でもいい様です。引数の「children」に「page.js」の内容が渡される様です。

import CommonLayout from "@/components/CommonLayout";

export default function Layout({ children }) {
  return (
    <CommonLayout>{children}</CommonLayout>
  );
}

余談ですが、1行目の「@/」はソースフォルダ「/src」を表していて、「../../」などの様に相対指定せずにコンポーネントをインポートできる様になっています。別の場所を指定したい場合は、「frontend\jsconfig.json」ファイルの4行目を編集します。

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

このままでは「layout.js」ファイルが多すぎて管理が大変そうなので、Next.jsの「Route Groups」という機能を使用して、以下の様にフォルダ構造を整理します。

src/
├─ app/
│   ├─ page.js            ← メインページ(レイアウトなし)
│   ├─ layout.js          ← 全体レイアウト 
│   └─ (CommonLayout)/
│       ├─ layout.js      ← 共通レイアウト使用
│       ├─ campgrounds/
│       │   ├─ edit/
│       │   │   └─ page.js
│       │   ├─ new/
│       │   │   └─ page.js
│       │   ├─ show/
│       │   │   └─ page.js
│       │   └─ page.js
│       ├─ login
│       │   └─ page.js
│       └─ register
│           └─ page.js
└─ components/
     └─ CommonLayout.jsx  ← 共通レイアウト

App Routerでは、「page.js」ファイルが存在するフォルダ内に「layout.js」ファイルがない場合、親フォルダのレイアウトファイルが適用される様です。そこでサブページだけに共通の親フォルダ「CommonLayout」等を作成して、その下に「layout.js」ファイルを配置します。しかしこのままではURLに「CommonLayout」が表示されてしまうので、フォルダ名を「()」で囲むと実際のURLからは除外されます。(Route Groupsという機能の様です。フォルダ名は任意の名称が使用できます。)

・メインページとサブページの<body>クラス名を変更します

YelpCampはメインページとサブページで<body>タグについているクラス名が異なるので、画面が遷移したらクラス名を変更する必要があります。レイアウトファイルで何とかしたいですが、App Routerのサブページのレイアウトファイルで、<head>や<body>タグの書き換えはできない様です。

export default function Layout({ children }) {
  return (
    // 子レイアウトで <body> の上書きは不可
    <body className="vh-100">
      {child}
    </body>
  );
}

画面が遷移したタイミングで、JavaScriptでDOMを直接操作すれば書き換えできそうですが、せっかくなのでReactコンポーネントで実装してみます。「src\components\BodyClass.js」というファイルを新たに作成して、以下のコードを追加します。少しテクニカルですが、メイン画面とサブ画面の<body>タグの差分クラスだけ入れ替えする様になっています。このコンポーネントはクライアント(ブラウザ)側で動作する様になっています。(クライアント側で実行するには「'use client';」ディレクティブをファイルの先頭に定義する様です。)

'use client';

import { useLayoutEffect } from 'react';
import { usePathname } from 'next/navigation';

// 既製クラスを“管理対象”としてホワイトリスト化(必要に応じて追加)
const MANAGED = new Set([
    'text-center', 'text-white', 'bg-dark', // メイン
    'flex-column', 'vh-100',                // サブ
]);

// basePath運用の場合の保険(.env などで NEXT_PUBLIC_BASE_PATH=/myappを定義可)
const BASE = process.env.NEXT_PUBLIC_BASE_PATH || '';

function normalize(pathname) {
    if (BASE && pathname.startsWith(BASE)) return pathname.slice(BASE.length) || '/';
    return pathname || '/';
}

// ルートごとの body クラス割り当てを定義(必要に応じて追加)
function classesFor(path) {
    if (path === '/') {
        return ['text-center', 'text-white', 'bg-dark']; // メイン
    } else {
        return ['flex-column', 'vh-100'];
    }
}

export default function BodyClass() {
    const raw = usePathname();
    const path = normalize(raw);

    useLayoutEffect(() => {
        const el = document.body;
        const next = new Set(classesFor(path));

        // 管理対象のうち「いらないもの」を外す(他のグローバルクラスは触らない)
        Array.from(el.classList).forEach((c) => {
            if (MANAGED.has(c) && !next.has(c)) el.classList.remove(c);
        });

        // 必要なのに付いていないものを付ける
        next.forEach((c) => {
            if (!el.classList.contains(c)) el.classList.add(c);
        });

        // Rootは基本アンマウントしないのでクリーンアップ不要
    }, [path]);

    return null;
}

呼び出し側では、おおもとのレイアウトファイル「frontend\src\app\layout.js」で以下の様に記述します。「FOUC」という現象を回避するため、できる限りサーバー側 (SSR) で実行して、クラス名の変更部分だけクライアント側で実行する様になっています。

import { cookies } from "next/headers";
import { AuthProvider } from "./context/AuthContext";
import BodyClass from "@/components/BodyClass";

async function fetchInitialUser() {
  // ブラウザのCookieをバックエンドに転送
  const cookieStore = await cookies();
  const cookieHeader = cookieStore.toString();
  const res = await fetch("http://localhost:3003/api/me", {
    headers: { Cookie: cookieHeader },
    cache: "no-store",
  });
  return res.ok ? await res.json() : null;
}

export default async function RootLayout({ children }) {
  const initialUser = await fetchInitialUser();
  return (
    <html lang="ja">
      <head>
        <title>YelpCamp デモ</title>
        <link href="https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css" rel="stylesheet" />
      </head>
      <body className='d-flex flex-column vh-100'>
        <BodyClass />
        <AuthProvider initialUser={initialUser}>
          {children}
        </AuthProvider>
      </body>
    </html>
  );
}

「FOUC (Flash of Unstyled Content)」とは、スタイルが適用される前に素のHTMLが一瞬表示される現象のことをいう様です。React などの SPA(CSR型)はサーバーがほぼ空のHTML(<div id="root"></div>等)を返すことが多いためFOUCが起きやすい様です。Next.jsなどでSSR/SSGを使用してHTMLをサーバー側で事前に生成しておくと軽減される様です。しかしクライアント側での「ハイドレーション (サーバー側のHTMLとReactを結合する処理)」を行う際にも僅かな表示の差が出るため、完全に防ぐのは難しいとされています(実用上はほぼゼロにできる様です)。そのため、動的なクラス操作は最小限にして、スタイルシートなどもできる限りサーバー側で読み込んでおいた方がいい様です。

テンプレートを作成します

もうすでにほぼ出来上がっていますが、サブページはヘッダーとフッターを持っていてすべて同じ構造なので以下の様なフォルダ構成にして、ヘッダーとフッターコンポーネントを追加します。「CommonLayout.jsx」ファイルは冗長なので削除して、内容を「(CommonLayout)\layout.js」ファイルに移動します。

src/
├─ app/
│   ├─ page.js            ← メインページ(レイアウトなし)
│   ├─ layout.js          ← 全体レイアウト 
│   └─ (CommonLayout)/
│       ├─ layout.js      ← サブページレイアウト
│       ├─ campgrounds/
│       │   ├─ edit/
│       │   │   └─ page.js
│       │   ├─ new/
│       │   │   └─ page.js
│       │   ├─ show/
│       │   │   └─ page.js
│       │   └─ page.js
│       └─ (user)
│           ├─ login
│           │   └─ page.js
│           └─ register
│               └─ page.js
└─ components/
     ├─ CommonLayout.jsx  ← 冗長なので削除
     ├─ Footer.js         ← フッター
     └─ NavBar.js         ← ヘッダー

「(CommonLayout)\layout.js」ファイルを以下の様に修正します。

import 'bootstrap/dist/css/bootstrap.min.css';
import '@/styles/app.css'

import BodyClass from '@/components/BodyClass';
import FlashMessages from '@/components/FlashMessages'
import Navbar from '@/components/NavBar';
import Footer from '@/components/Footer';
import Scripts from './scripts';

export default function SubLayout({ children }) {
  return (
    <>
      <BodyClass classes="d-flex flex-column vh-100" />

      <Navbar />
      <main className="container mt-5">
        <FlashMessages />
        {children}
      </main>
      <Footer />

      <Scripts />
    </>
  );
}
コントローラーを作成します

今回の構成 (React + Next.js + Go) ではデータベース操作などのコントローラー機能はバックエンドのGo環境上に実装します。フロントエンドからは「Axios」というnpmモジュールを使用して、API方式でJsonデータをやり取りします。

・Goで仮のAPI サーバーを作成します

まずは、フロントエンド側にデータの送受信機能を実装したいので、バックエンドのGo環境に通信テスト用のJsonサーバーを構築します。折角なのでChatGPTを使用して、以下のプロンプトで自動作成してもらいます。18行目の「CORS」条件は結構重要です。この機能が無いとフロントエンド(Axios)と通信できない様です。(後でご説明する Next.js のリバースプロキシ機能を使用すると不要になる様です。)

以下の仕様の Go プログラムを作成してください。

・バックエンドで使用する Json API サーバー
・以下の Json 構造でテーブル名は「campground」
{
    "campground": [
        {
            "Id": 0,
            "Title": "",
            "Price": 0,
            "Description": "",
            "Location": "",
            "Author": "",
            "Geometry": ""
        }
    ]
}
・フロントエンドからaxiosでアクセスできる様にCORS有効で
・待ち受けポートは「3003」番
・データベースは「db.json」というファイルを使用
・送受信データにテーブル名「campground」は付加しない
・受信データに「Id」も含める
・登録時は「Id」を自動採番
・「localhost:3003/campground ですべてのデータを取得
・「localhost:3003/campground」にポストするとデータを追加
・「localhost:3003/campground/id」で指定のデータを取得
・「localhost:3003/campground/id」にPATCHで修正
・「localhost:3003/campground/id」にDELETEで削除
・できるだけ簡単に

以上でお願いします。

わずか6秒で以下のコードが生成されました。手作業で作っていたら大変でした。

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
)

const (
	addr    = ":3003"
	dbFile  = "db.json"
	route   = "/campground"
	routeId = "/campground/"
)

type Campground struct {
	Id          int    `json:"Id"`
	Title       string `json:"Title"`
	Price       int    `json:"Price"`
	Description string `json:"Description"`
	Location    string `json:"Location"`
	Author      string `json:"Author"`
	Geometry    string `json:"Geometry"`
}

type database struct {
	Campground []Campground `json:"campground"`
}

var (
	mu     sync.Mutex
	db     database
	nextID int
)

func main() {
	if err := loadOrInitDB(); err != nil {
		panic(err)
	}
	mux := http.NewServeMux()
	mux.HandleFunc(route, withCORS(handleCollection))
	mux.HandleFunc(routeId, withCORS(handleItem))

	fmt.Println("JSON API server listening on", addr)
	if err := http.ListenAndServe(addr, mux); err != nil {
		panic(err)
	}
}

// ---- Handlers ----

func handleCollection(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		getAll(w, r)
	case http.MethodPost:
		create(w, r)
	case http.MethodOptions:
		w.WriteHeader(http.StatusNoContent)
	default:
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
	}
}

func handleItem(w http.ResponseWriter, r *http.Request) {
	idStr := strings.TrimPrefix(r.URL.Path, routeId)
	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, "invalid id", http.StatusBadRequest)
		return
	}
	switch r.Method {
	case http.MethodGet:
		getOne(w, r, id)
	case http.MethodPatch:
		patch(w, r, id)
	case http.MethodDelete:
		del(w, r, id)
	case http.MethodOptions:
		w.WriteHeader(http.StatusNoContent)
	default:
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
	}
}

// ---- CRUD ----

func getAll(w http.ResponseWriter, _ *http.Request) {
	mu.Lock()
	defer mu.Unlock()
	writeJSON(w, http.StatusOK, db.Campground)
}

func getOne(w http.ResponseWriter, _ *http.Request, id int) {
	mu.Lock()
	defer mu.Unlock()
	for _, cg := range db.Campground {
		if cg.Id == id {
			writeJSON(w, http.StatusOK, cg)
			return
		}
	}
	http.Error(w, "not found", http.StatusNotFound)
}

func create(w http.ResponseWriter, r *http.Request) {
	var in Campground
	if err := decodeJSON(r.Body, &in); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}

	mu.Lock()
	defer mu.Unlock()

	in.Id = nextID // 自動採番(受信に Id が含まれていても上書き)
	nextID++
	db.Campground = append(db.Campground, in)

	if err := saveDB(); err != nil {
		http.Error(w, "failed to save", http.StatusInternalServerError)
		return
	}
	writeJSON(w, http.StatusCreated, in)
}

func patch(w http.ResponseWriter, r *http.Request, id int) {
	var updates map[string]interface{}
	if err := decodeJSON(r.Body, &updates); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}

	mu.Lock()
	defer mu.Unlock()

	i := indexByID(id)
	if i == -1 {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	// 既存を取得
	cg := db.Campground[i]

	// Id は不変にする
	if v, ok := updates["Title"].(string); ok {
		cg.Title = v
	}
	if v, ok := numberToInt(updates["Price"]); ok {
		cg.Price = v
	}
	if v, ok := updates["Description"].(string); ok {
		cg.Description = v
	}
	if v, ok := updates["Location"].(string); ok {
		cg.Location = v
	}
	if v, ok := updates["Author"].(string); ok {
		cg.Author = v
	}
	if v, ok := updates["Geometry"].(string); ok {
		cg.Geometry = v
	}

	db.Campground[i] = cg
	if err := saveDB(); err != nil {
		http.Error(w, "failed to save", http.StatusInternalServerError)
		return
	}
	writeJSON(w, http.StatusOK, cg)
}

func del(w http.ResponseWriter, _ *http.Request, id int) {
	mu.Lock()
	defer mu.Unlock()

	i := indexByID(id)
	if i == -1 {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	// 削除
	db.Campground = append(db.Campground[:i], db.Campground[i+1:]...)
	if err := saveDB(); err != nil {
		http.Error(w, "failed to save", http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// ---- DB I/O ----

func loadOrInitDB() error {
	mu.Lock()
	defer mu.Unlock()

	// ない場合は初期化
	if _, err := os.Stat(dbFile); errors.Is(err, os.ErrNotExist) {
		db = database{Campground: []Campground{}}
		nextID = 1
		return saveDB()
	}

	f, err := os.Open(dbFile)
	if err != nil {
		return err
	}
	defer f.Close()

	if err := json.NewDecoder(f).Decode(&db); err != nil {
		return err
	}

	// nextID を決定
	maxID := 0
	for _, cg := range db.Campground {
		if cg.Id > maxID {
			maxID = cg.Id
		}
	}
	nextID = maxID + 1
	return nil
}

func saveDB() error {
	tmp := filepath.Join(filepath.Dir(dbFile), "."+filepath.Base(dbFile)+".tmp")
	b, err := json.MarshalIndent(db, "", "  ")
	if err != nil {
		return err
	}
	if err := os.WriteFile(tmp, b, 0644); err != nil {
		return err
	}
	return os.Rename(tmp, dbFile)
}

// ---- Utils ----

func numberToInt(v interface{}) (int, bool) {
	switch n := v.(type) {
	case float64:
		return int(n), true
	case int:
		return n, true
	case json.Number:
		i, err := strconv.Atoi(n.String())
		if err != nil {
			return 0, false
		}
		return i, true
	default:
		return 0, false
	}
}

func indexByID(id int) int {
	for i, cg := range db.Campground {
		if cg.Id == id {
			return i
		}
	}
	return -1
}

func decodeJSON(r io.ReadCloser, v any) error {
	defer r.Close()
	dec := json.NewDecoder(r)
	dec.UseNumber()
	return dec.Decode(v)
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

// シンプルな CORS ミドルウェア
func withCORS(h http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
		// Preflight
		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		h.ServeHTTP(w, r)
	}
}

「backend\db.json」というファイルを作成してサンプルデータを登録します。

{
  "campground": [
    {
      "Id": 1,
      "Title": "エンシェント・ベイショア",
      "Price": 1424,
      "Description": "エンシェント・ベイショアの説明",
      "Location": "愛知県小牧市",
      "Author": "test",
      "Geometry": "{'type':'Point','coordinates':[136.933907,35.306753]}"
    },
    {
      "Id": 2,
      "Title": "ペトリファイド・ベイ",
      "Price": 2551,
      "Description": "ペトリファイド・ベイの説明",
      "Location": "埼玉県北本市",
      "Author": "test",
      "Geometry": "{'type':'Point','coordinates':[139.53513,36.032346]}"
    }
  ]
}

簡単な動作確認をしたいので、Ctrl + @ でターミナル画面を開いて以下の様に入力して、Goサーバーを起動します。(デバッガーを使用したい場合は、この次の処理を実行してください。)

cd c:\MAMP\htdocs\YelpCampReact\backend
go run main.go

ブレークポイントを使用して確認したい場合は、VSCodeの実行とデバッグドロップダウンから「Backend: Go」を選択してデバッガーを起動します。生成されたコードの59行目と72行目にブレークポイントをセットしておくとすべてのリクエストをキャッチできます。

ブラウザーを開いてURL欄に「localhost:3003/campground」と入力するとJsonデータが取得できている様です。

・フロントエンド:すべてのデータの取得機能を追加します

VSCodeのターミナル画面に以下の様に入力して、Axiosパッケージをインストールします。

cd c:\MAMP\htdocs\YelpCampReact\frontend
npm i axios

実際のコードとは異なりますが、以下の様に記述してデータの取得と表示を行います。11行目でAxiosを使用して、Jsonサーバーからデータを取得しています。得られたデータはres.dataにJsonオブジェクトの配列として格納されていますので、「setCampgrounds」ステート関数を使用して「campgrounds」変数に保存します。その後、20行目から始まる「map」関数で配列を個別に操作して、それぞれのデータを表示しています。

ご説明が前後しましたが、7行目と9行目にある関数は「フック関数(Hooks)」と呼ばれています。フック関数とは関数コンポーネントの状態管理や、副作用と呼ばれるレンダリング以外の処理などを行うための仕組みで、あらかじめ組み込みの関数がいくつも定義されています。カスタムフックとして自分で定義する事もできる様です。フック関数を使用するには3行目にある様に事前にインポートしておく必要があります。

'use client';

import { useState, useEffect } from "react";
import axios from "axios"

export default function Home() {
    const [campgrounds, setCampgrounds] = useState();

    useEffect(() => {
        const getCampground = async () => {
            const res = await axios.get('http://localhost:3003/campground')
            setCampgrounds(res.data);
        }
        getCampground();
    }, [])

    return (
        <>
            <h1>キャンプ場一覧</h1>
            {campgrounds?.map(campground => {
                return (
                    <div key={campground.Id}
                        style={{ border: '2px solid lightGray', margin: '5px'}}>
                        <p>名称:{campground.Title}</p>
                        <p>価格:¥{campground.Price}</p>
                        <p>詳細:{campground.Description}</p>
                        <p>場所:{campground.Location}</p>
                    </div>
                )
            })}
        </>
    );
}
・フロントエンド:データ送信機能を追加します

まずはサーバーに送信するためのデータ構造を定義します。共通に使用するデータ構造ファイルの保存場所は特に決まっていない様です。今回は以下のフォルダ内に定義しました。

src/
├─ app/
├─ components/
└─ lib/
     └─ CampgroundFormData.js ← キャンプグラウンドデータ定義

「CampgroundFormData.js」ファイルの中身は以下の様になっています。メンバー定義を兼ねたコンストラクターと、「updateField」という更新関数、クラスオブジェクトをJson形式に変換する「toJSON」という関数が定義されています。Jsonに変換する場合は、Go側で定義されている形式に合わせる必要があります。

export default class CampgroundFormData {
        constructor({ Id = 0, Title = '', Price = 0, Description = '', Location = '', Author = '', Geometry = '' } = {}) {
        this.Id = Id;
        this.Title = Title;
        this.Price = Price;
        this.Description = Description;
        this.Location = Location;
        this.Author = Author;
        this.Geometry = Geometry;
    }

    updateField(field, value) {
        return new CampgroundFormData({
            ...this,
            [field]: value,
        });
    }

    toJSON() {
        return {
            Id: parseInt(this.Id, 10) || 0,
            Title: this.Title,
            Price: parseInt(this.Price, 10) || 0,
            Description: this.Description,
            Location: this.Location,
            Author: this.Author,
            Geometry: this.Geometry
        };
    }
}

キャンプ情報を登録するコンポーネントで、このクラスのインスタンスを作成するには、以下の様に記述します。「useState」フック関数で各メンバー用のステート変数と保存関数を定義して、<input>や<textarea>エレメントの「value」属性と「onChange」イベントにそれぞれ設定します。あとは登録ボタンが押された時に呼び出される「handleSubmit」関数内で送信用データを作成して、JsonサーバーにAxiosのポスト関数で送信します。

'use client';

import axios from 'axios';
import React, { useState } from 'react';
import CampFormData from '@/lib/CampgroundFormData';

export default function CreateCampground() {
    const [title, setTitle] = useState('');
    const [price, setPrice] = useState('');
    const [description, setDescription] = useState('');
    const [location, setLocation] = useState('');
    const [response, setResponse] = useState(null);

    const handleSubmit = async (e) => {
        e.preventDefault();

        // 自前クラスでオブジェクト作成
        const camp = new CampFormData({
            Title: title,
            Price: price,
            Description: description,
            Location: location
        });

        try {
            const res = await axios.post('http://localhost:3003/campground', camp.toJSON());
            setResponse(res.data.message);
        } catch (error) {
            console.error(error);
            setResponse('エラーが発生しました');
        }
    };

    return (
        <>
            <h1>キャンプ場の新規登録</h1>

            <form onSubmit={handleSubmit}>
                <div>
                    <label>名称:</label>
                    <input type="text"
                        value={title}
                        onChange={(e) => setTitle(e.target.value)} required />
                </div>
                <div>
                    <label>価格:</label>
                    <input type="number"
                        value={price}
                        onChange={(e) => setPrice(e.target.value)} required />
                </div>
                <div>
                    <label>詳細:</label>
                    <textarea type="text"
                        value={description}
                        onChange={(e) => setDescription(e.target.value)} required />
                </div>
                <div>
                    <label>場所:</label>
                    <input type="text"
                        value={location}
                        onChange={(e) => setLocation(e.target.value)} required />
                </div>
                <button type="submit">登録</button>
            </form>
            {response && <p>サーバーからの応答: {response}</p>}
        </>
    );
}

多数のステート変数を定義したくない場合は、「useReducer」というフック関数を使用すればステート変数を使用せずに、クラスオブジェクトを直接使用できる様になります。(サンプル例では、useReducerで定義した「dispatch」関数が、各エレメントの「name」と「value」の値を使用してリデューサ関数「formReducer」を呼び出してオブジェクトを更新(作成)しています。各エレメントの「name」属性に更新したいメンバー名を設定しておく必要があります。)

'use client';

import axios from 'axios';
import React, { useState, useReducer } from 'react';
import CampFormData from '@/lib/CampgroundFormData';

// Reducer 関数
function formReducer(state, action) {
    switch (action.type) {
        case 'UPDATE_FIELD':
            return state.updateField(action.field, action.value);
        case 'RESET':
            return new CampFormData(); // 初期化
        default:
            return state;
    }
}

export default function Campgrounds() {
    const [formData, dispatch] = useReducer(formReducer, new CampFormData());
    const [response, setResponse] = useState(null);

    const handleChange = (e) => {
        const { name, value } = e.target;
        dispatch({ type: 'UPDATE_FIELD', field: name, value });
    };

    const handleSubmit = async (e) => {
        e.preventDefault();

        try {
            const res = await axios.post('http://localhost:3003/campground', formData.toJSON());
            dispatch({ type: 'RESET' });
            setResponse(res.data.message);
        } catch (err) {
            console.error(err);
            setResponse('エラーが発生しました');
        }
    };

    return (
        <>
            <h1>キャンプ場の新規登録</h1>

            <form onSubmit={handleSubmit}>
                <div>
                    <label>名称:</label>
                    <input type="text"
                        name="Title" value={formData.Title}
                        onChange={handleChange} required />
                </div>
                <div>
                    <label>価格:</label>
                    <input type="number"
                        name="Price" value={formData.Price}
                        onChange={handleChange} required />
                </div>
                <div>
                    <label>詳細:</label>
                    <textarea type="text"
                        name="Description" value={formData.Description}
                        onChange={handleChange} required />
                </div>
                <div>
                    <label>場所:</label>
                    <input type="text"
                        name="Location" value={formData.Location}
                        onChange={handleChange} required />
                </div>
                <button type="submit">登録</button>
            </form>
            {response && <p>サーバーからの応答: {response}</p>}
        </>
    );
}
・フロントエンド:編集機能を追加します

編集機能は、データを取得してAxiosの「put」または「patch」関数でサーバーに送信すれば実現できますが、個別のレコードが対象になるので、Next.js の 「動的ルートセグメント」という機能を使用します。以下のツリーの[id]の様にカッコ([])でフォルダ名を囲むと「campgrounds/123/edit」等のURLにマッチさせることができます。「campgrounds/new」もマッチしそうですが、 ルーティングの優先順位は 「静的(new) > 動的([id]) > キャッチオール([...folderName]) 」という順番で評価される様です。

src/
└─ app/
    └─ (CommonLayout)/
        ├─ layout.js
        └─ campgrounds/
                ├─ [id]   ← 動的ルートセグメント
                │   └─ edit/
                │      └─ page.js
                ├─ new/
                │   └─ page.js
                └─ page.js

マッチしたURLの値は、動的セグメントフォルダ内の「page.js」で以下の様に取得できる様です(クライアントサイドの場合)。パラメータ名(id等)は、大文字小文字含めフォルダ名と同じにします。

'use client';

import { useParams } from 'next/navigation';

export default function SubPage() {
    const { id } = useParams();
    return (<h1>{`サブページ ${id}`}</h1>);
}

サーバーサイドの場合は、以下の様にすると取得できるようです(Next.js バージョン15以上の場合)。関数のパラメータ名「params」は他の変数名には変更できない様です。

export default async function SubPage({ params }) {
    const { id } = await params;
    return (<h1>{`サブページ ${id}`}</h1>);
}

また、数値だけにマッチさせるという様な設定はできないため、そのような処理が必要な場合は正規表現等を使用して処理を書く必要があります。

'use client';

import { useParams, useRouter } from 'next/navigation';
import { useEffect } from 'react';

export default function Page() {
  const { id } = useParams(); // params.id は常に string
  const router = useRouter();

  useEffect(() => {
    // 数値でない場合はトップページにリダイレクト
    if (!/^\d+$/.test(id as string)) {
      router.replace('/'); 
    }
  }, [id, router]);

  return <h1>サブページ {id}</h1>;
}

編集データの取得時は個別のレコードがjson形式で返ってきますので、先ほどご説明したリデューサ関数「formReducer」に以下の項目を追加しておくと、取得したJsonデータを直接「formData」オブジェクトに変換できます。

// Reducer 定義
function formReducer(state, action) {
    switch (action.type) {
        case 'UPDATE_FIELD':
            return state.updateField(action.field, action.value);
        case 'SET_ALL':
            return action.payload; // 取得データで置き換え
        case 'RESET':
            return new CampFormData(); // 初期化
        default:
            return state;
    }
}

データの取得部分は以下の様に変更します。取得したレコードのJsonデータから新しい「formData」インスタンスを作成して、「dispatch」関数でリデューサ関数に渡してあげると、新しく作成されたオブジェクトで「formData」変数が更新されます。

export default function ShowCampground() {
    const [formData, dispatch] = useReducer(formReducer, undefined, () => new CampFormData());
    const [response, setResponse] = useState(null);
    const router = useRouter();
    const { id } = useParams();

    useEffect(() => {
        const getCampground = async () => {
            try {

                const res = await axios.get(`http://localhost:3003/campground/${id}`);
                dispatch({
                    type: 'SET_ALL', payload: new CampFormData(res.data),
                });
            }
            catch (error) {
                console.error("キャンプ場データ取得エラー:", error);
            }
        };

        if (id) {
            getCampground();
        }
    }, [id]); // id が変わったら再取得
・フロントエンド:削除機能を追加します

削除機能の追加は簡単です。削除ボタンの「onClick」ハンドラーに削除関数を登録します。(jsxではイベント名等のキーワードを「onclick」ではなくて「onClick」の様にキャメルケース(ローワーキャメルケース)と呼ばれる記法で記述する様です。)

<button class="btn btn-danger" onClick={deleteCampground}>削除する</button>

後はイベントハンドラー内でAxiosのdelete関数を使用してAPIを呼び出します。対象のデータはURL内で指定していますので、2番目の引数は必要ありません。

const deleteCampground = async (e) => {
    e.preventDefault();

    try {
        const res = await axios.delete(`http://localhost:3003/campground/${formData.id}`);
        dispatch({ type: 'RESET' });
        setResponse(res.data.message);
        router.push('/campgrounds');
    } catch (err) {
        console.error(err);
        setResponse('エラーが発生しました');
    }
}
・バックエンド:コントローラーを作成します

以下のプロンプトを入力して、ChatGPTで一気にGo側の処理を作成します。

Go で Gin を使用した REST API (ルーティング) 機能を作成してください。

・以下の様にコントローラーを個別のファイルに分割してください。
controller/
├─ CampgroundsController.go
├─ ReviewsController.go
└─ UsersController.go

・以下は Laravel の例ですが、Gin の Group 関数を使用してエンドポイントを
 対応するコントローラーに振り分けてください。

Route::controller(CampgroundsController::class)->group(function () {
    Route::get('campgrounds', 'index')->name('campgrounds.index');
    Route::get('campgrounds/new', 'new')->middleware('auth');
    Route::post('campgrounds', 'createCampground')->middleware('auth');
    Route::get('campgrounds/{id}', 'show');
    Route::get('campgrounds/{id}/edit', 'edit')->middleware('isAuthor');
    Route::put('campgrounds/{id}', 'updateCampground')->middleware('isAuthor');
    Route::delete('campgrounds/{id}', 'deleteCampground')->middleware('isAuthor');
});

Route::controller(ReviewsController::class)->group(function () {
    Route::post('campgrounds/{id}/reviews', 'createReview')->middleware('auth');
    Route::delete('campgrounds/{id}/reviews/{review_id}', 'deleteReview')->middleware('isReviewAuthor');
});

Route::controller(UsersController::class)->group(function () {
    Route::get('login', 'index')->middleware('guest')->name('login');
    Route::post('login', 'login')->middleware('guest');
    Route::get('register', fn() => view('users.register'));
    Route::post('register', 'register');
    Route::get('logout', 'logout');
});

・router.go というファイルを作成して、グループの登録処理をその中にまとめてください。

・ミドルウェアのひな型は「middleware.go」というファイルに作成してください。

・グループ化はできるだけ長くマッチする部分を抜き出してください。

・コントローラ毎にグループを分けてください。

・それぞれのコントローラー内で、受け取った Json を以下のオブジェクトに変換するところまで実装してください。

type Campground struct {
	Id          int    
	Title       string 
	Price       int    
	Description string 
	Location    string 
	Author      string  
	Geometry    string 
}

type User struct {

    UserName    string  
    Email       string
    Password    string  
}

type Review struct {
    Id          int
    Body        string
    Rating      int     
    CampId      int     
    Author      string 
}

以上です。

32秒で作成されました。以下のフォルダ構成にして、生成されたコードを保存します。各ファイルの詳細はお手数ですが、ソースコードをご覧ください。

backend/
├─ controller/
│  ├─ CampgroundsController.go
│  ├─ ReviewsController.go
│  └─ UsersController.go
├─ middleware/
│  └─ middleware.go
├─ models/
│  ├─ Campground.go
│  ├─ Review.go
│  └─ User.go
├─ router/
│  └─ router.go
├─ go.mod
└─ main.go

一部ご紹介すると、生成に使用したLaravelのルーティング関数は以下の様なGo関数に変換されました。(生成されたコードはそのままでは使用できない(エラーになる)ので、パッケージ名や「middleware.」等のパッケージ指定部分は手動で修正しました。また、フロントエンドとバックエンドのURLを区別したいので、2行目に「/api」グループを追加して、バックエンド側は「/localhost:3003/api/campgrounds」等のURLでアクセスできる様に修正しました。)

func SetupRoutes(r *gin.Engine) {
	api := r.Group("/api")
	{
		// ========== Campgrounds ==========
		camps := api.Group("/campgrounds")
		{
			camps.GET("", controller.CampgroundsIndex) // /campgrounds
			camps.GET("/new", middleware.AuthRequired(), controller.CampgroundsNew)
			camps.POST("", middleware.AuthRequired(), controller.CreateCampground)

			byID := camps.Group("/:id") // /campgrounds/:id ...
			{
				byID.GET("", controller.ShowCampground)
				byID.GET("/edit", middleware.IsAuthor(), controller.EditCampground)
				byID.PUT("", middleware.IsAuthor(), controller.UpdateCampground)
				byID.DELETE("", middleware.IsAuthor(), controller.DeleteCampground)
			}
		}

		// ========== Reviews ==========
		// Reviews は Campgrounds 配下の最長一致 (/campgrounds/:id/reviews) をグループ化
		reviews := api.Group("/campgrounds/:id/reviews")
		{
			reviews.POST("", middleware.AuthRequired(), controller.CreateReview)
			reviews.DELETE("/:review_id", middleware.IsReviewAuthor(), controller.DeleteReview)
		}

		// ========== Users ==========
		users := api.Group("/") // ルート直下で Users をまとめる
		{
			users.GET("/login", middleware.GuestOnly(), controller.LoginIndex)
			users.POST("/login", middleware.GuestOnly(), controller.Login)
			users.GET("/register", controller.RegisterIndex)
			users.POST("/register", controller.Register)
			users.GET("/logout", controller.Logout)
		}
	}
}

以下の様なメイン関数も生成されたので、最初に作成した「main.go」ファイルを書き換えます。

package main

import (
	"log"
	"yelpcampreact/router"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()
	router.SetupRoutes(r)

	if err := r.Run(":3003"); err != nil {
		log.Fatal(err)
	}
}

Next.jsには「リバースプロキシ(rewrite)」という機能がある様です。この機能を使用すればフロント側とエンド側が同一オリジンとみなされるため、Goサーバー側で「CORS」設定が不要になります。設定を有効にするには、フロント側の「frontend\next.config.mjs」というファイルを開いて以下の設定を追加します。React(Next.js)を再起動すると設定が有効になります。

/** @type {import('next').NextConfig} */
const nextConfig = {
  async rewrites() {
    return [
      {
        source: "/api/:path*",
        destination: "http://localhost:3003/api/:path*",
      },
    ];
  },
};

export default nextConfig;

バックエンド側を呼び出す場合は、以下の様にURLに「/api」を追加します。

    useEffect(() => {
        const getCampground = async () => {
            const res = await axios.get('/api/campgrounds')
            setCampgrounds(res.data);
        }
        getCampground();
    }, [])
・バックエンド:必要なパッケージをインストールします

標準パッケージ以外を使用したので、VSCodeにCtrl + @と入力して、表示されたターミナル画面に以下のコマンドを入力して、「Gin」パッケージをインストールしておきます。

cd c:\MAMP\htdocs\YelpCampReact\backend
go get -u github.com/gin-gonic/gin
データベースを作成します

YelpCampアプリが使用するMySQLデータベースを作成します。エクスプローラーを起動して、上部のパス入力欄に「C:\MAMP\bin\mysql\bin」と入力してenterキーを押します。そのまま同じ入力欄に「cmd」と入力してenterキーを押して、Windowsのコマンドプロンプトを表示させます。データベースアプリを起動するためにMAMPも起動しておきます。

表示されたウィンドウに「mysql -uroot -proot」と入力して、MySQLのコマンドプロンプトを表示させます。

C:\MAMP\bin\mysql\bin>mysql -uroot -proot
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 3
Server version: 5.7.24 MySQL Community Server (GPL)

Copyright (c) 2000, 2018, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>

MySQLのプロンプトで、以下の様に入力してデータベースとユーザーを作成します。(データベース名やユーザー名、パスワードはご自身の環境に合わせて適宜変更してください。)

create database yelpcampreact;
grant all privileges on yelpcampreact.* to yelpcampreact@'localhost' identified by 'password'; 
exit;
モデルを作成します

コントローラーのひな型まで作成できましたので、次はGo環境にデータベースの操作ができるモデル機能を追加します。またChatGPTに助けてもらいます。いきなりすべての機能は作成できないと思われますので、まずは以下のプロンプトでSQL文を作成してもらいます。

以下の条件でMySQLのSQL(CREATE TABLE)文を作成してください。

・各テーブルのデータ構造は以下の様になっています。

type Campground struct {
	Id          int    // 主キー、オートインクリメント
	Title       string 
	Price       int    
	Description string 
	Location    string 
	Author      string  // NULL許容
	Geometry    string 
}

type User struct {

    UserName    string  // ユニークキー
    Email       string
    Password    string  // パスワード 保存時にハッシュ化
}

type Image struct {
    Id          int     // 主キー、オートインクリメント
    Filename    string
    CampId      int     // Campground.Id の外部キー
    Thumbnail   string
}

type Review struct {
    Id          int
    Body        string
    Rating      int     // デフォルト値は 1
    CampId      int     // Campground.Id の外部キー
    Author      string  // NULL許容
}

・カスケ―ディング条件は記述しないでください。
・外部キーはインデックス化してください。
・大文字と小文字の比較は無効にしてください。

以上でお願いします。

13秒で以下のSQL文を生成してくれました。なぜかユーザー名のサイズが191文字ですが、特に問題ないと思われます。「Reviews」テーブルの主キーの指定を付け忘れましたが、他のテーブルと同様に「Id」を主キーにしてくれた様です。また驚いたことに、何も指定していないのに各テーブルの「Author」項目をUsersテーブルの「UserName」の外部キーとして先読みしてくれています。

-- データベース(任意)
CREATE DATABASE IF NOT EXISTS campapp
  DEFAULT CHARACTER SET utf8mb4
  DEFAULT COLLATE utf8mb4_0900_ai_ci;
USE campapp;

-- Users
CREATE TABLE IF NOT EXISTS users (
  UserName   VARCHAR(191) NOT NULL,         -- ユニークキー
  Email      VARCHAR(255) NOT NULL,
  Password   VARCHAR(255) NOT NULL,         -- ハッシュを保存
  PRIMARY KEY (UserName),
  UNIQUE KEY uq_users_username (UserName)
) ENGINE=InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_0900_ai_ci;

-- Campgrounds
CREATE TABLE IF NOT EXISTS campgrounds (
  Id          INT NOT NULL AUTO_INCREMENT,  -- 主キー、オートインクリメント
  Title       VARCHAR(255) NOT NULL,
  Price       INT NOT NULL,
  Description TEXT,
  Location    VARCHAR(255) NOT NULL,
  Author      VARCHAR(191) NULL,            -- users(UserName) を参照、NULL許容
  Geometry    TEXT NOT NULL,                -- 文字列として保持(必要なら型変更可)
  PRIMARY KEY (Id),
  INDEX idx_campgrounds_author (Author),
  CONSTRAINT fk_campgrounds_author
    FOREIGN KEY (Author) REFERENCES users (UserName)
) ENGINE=InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_0900_ai_ci;

-- Images
CREATE TABLE IF NOT EXISTS images (
  Id         INT NOT NULL AUTO_INCREMENT,   -- 主キー、オートインクリメント
  Filename   VARCHAR(255) NOT NULL,
  CampId     INT NOT NULL,                  -- Campground.Id の外部キー
  Thumbnail  VARCHAR(255) NOT NULL,
  PRIMARY KEY (Id),
  INDEX idx_images_campid (CampId),
  CONSTRAINT fk_images_campid
    FOREIGN KEY (CampId) REFERENCES campgrounds (Id)
) ENGINE=InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_0900_ai_ci;

-- Reviews
CREATE TABLE IF NOT EXISTS reviews (
  Id      INT NOT NULL AUTO_INCREMENT,
  Body    TEXT NOT NULL,
  Rating  INT NOT NULL DEFAULT 1,           -- デフォルト値は 1
  CampId  INT NOT NULL,                     -- Campground.Id の外部キー
  Author  VARCHAR(191) NULL,                -- users(UserName) を参照、NULL許容
  PRIMARY KEY (Id),
  INDEX idx_reviews_campid (CampId),
  INDEX idx_reviews_author (Author),
  CONSTRAINT fk_reviews_campid
    FOREIGN KEY (CampId) REFERENCES campgrounds (Id),
  CONSTRAINT fk_reviews_author
    FOREIGN KEY (Author) REFERENCES users (UserName)
) ENGINE=InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_0900_ai_ci;

さらに以下のプロンプトで、データベースを操作するコードを作成してもらいます。

このSQL文から Go で CRUD 操作できるコードを作成してください。

・外部ライブラリー(GORM)を使用してできるだけ簡単に
・マイグレーション機能も実装してください。
・テーブルごとに「campground.go」等のファイルに分割してください。
・バックエンド用途ですが、REST APIは実装しないでください。

28秒で「campapp-gorm.zip」というファイルを作成してくれました。ファイル構成は以下の様になっています。まずは動作確認を行うために、「campapp-gorm」フォルダをVSCodeで開きます。

campapp-gorm/
├─ internal/
│   ├─ database/
│   │   └─ db.go
│   └─ models/
│       ├─ campground.go
│       ├─ image.go
│       ├─ review.go
│       └─ user.go
├─ .env.example
├─ go.mod
├─ main.go
└─ README.md

今回も外部パッケージを使用したので、VSCodeのターミナル画面に以下のコマンドを入力して、「GORM」パッケージをインストールしておきます。MySQL用のドライバー・パッケージもインストールします。

go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql

開発時に「.env」ファイルを使用したいので、以下のパッケージも追加でインストールします。何かエラーが出る場合は、2行目の「go mod tidy」というコマンドを実行すると、パッケージの依存関係を解決してくれる様です。

go get -u github.com/joho/godotenv
go mod tidy 

Goのプロジェクトフォルダに「.env」というファイルを作成して、以下の設定を書き込んで保存します。ひな型となるDSNファイルもChatGPTが作成してくれましたが、MAMPに含まれているMySQLのバージョンは「5.7.24」と少し古めなので、文字列を比較するオプション「utf8mb4_0900_ai_ci」を「utf8mb4_unicode_ci」に変更しておきます。DSN(データベースの接続文字列)は「<ユーザー名>:<パスワード>@<ホスト>/<データベース名>?<オプション>」という構造になっている様です。

# user:pass@tcp(127.0.0.1:3306)/campapp?charset=utf8mb4&collation=utf8mb4_0900_ai_ci&parseTime=True&loc=Local
DSN=yelpcampreact:password@tcp(localhost:3306)/yelpcampreact?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local

ChatGPTが生成してくれた、メインパッケージの16行目にある「utf8mb4_0900_ai_ci」オプションも「utf8mb4_unicode_ci」に変更しておきます。また、自動的に「.env」ファイルは読み込まれない様なので、29行目から31行目に起動時にファイルを読み込む処理を追加しました。

package main

import (
	"fmt"
	"log"
	"os"

	"example.com/campapp-gorm/internal/database"
	"example.com/campapp-gorm/internal/models"
	"github.com/joho/godotenv"
	"gorm.io/gorm"
)

func migrate(db *gorm.DB) {
	// テーブルオプション: 照合・文字セットを明示(大文字小文字を区別しない)
	db = db.Set("gorm:table_options", "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci")

	if err := db.AutoMigrate(
		&models.User{},
		&models.Campground{},
		&models.Image{},
		&models.Review{},
	); err != nil {
		log.Fatalf("auto migrate failed: %v", err)
	}
}

func main() {
	if err := godotenv.Load(); err != nil {
		log.Println("No .env file found")
	}

	dsn := os.Getenv("DSN")
	db := database.ConnectMySQL(dsn)

	migrate(db)
	fmt.Println("migrated successfully.")
}

VSCodeのターミナル画面に「go run main.go」と入力すると正常に実行されました。データテーブルもマイグレーション機能で正常に作成された様です。

PS C:\MAMP\htdocs\go-projects\src\campapp-gorm> go run main.go
migrated successfully.
PS C:\MAMP\htdocs\go-projects\src\campapp-gorm> 

データベースの操作関数も作成できたので、YelpCampアプリに組み込んでいきます。ChatGPTで作成したアプリの「internal」フォルダ以下はそのまま使用できるので、「database」フォルダと「models」フォルダ以下を丸ごとコピーして持ってきます。

次に、メイン関数にあるデータベース操作用のインスタンス「db」を各コントローラーに渡したいのですが、グローバル変数にしてしまうとメンテナンスやテストする際に支障が出そうなので、構造体を作成してメンバーとして保持させて依存性を注入できる様にします。まずは「controller\Handlers.go」というファイルを作成して以下の様に定義します。

package controller

import "gorm.io/gorm"

type Handlers struct {
	DB *gorm.DB
}

コントローラー関数のひな型は、関数名の前に「(h *Handlers)」を追加して「Handlers」構造体の「レシーバ(関数)」として定義します。見慣れない形ですが、この関数はオブジェクト指向言語などの「メンバー関数」の様な物として捉えると理解しやすいかもしれません。メンバー関数の一種なので、構造体に定義されているメンバー「DB」に関数内から自由にアクセスできる様になります。

// GET /campgrounds
func (h *Handlers) CampgroundsIndex(c *gin.Context) {
	c.JSON(http.StatusOK, []string{})
}

メイン関数を以下の様に修正します。11行目で構造体をDB変数で初期化して、14行目のルーターセットアップ関数に引数として渡します。

func main() {
	if err := godotenv.Load(); err != nil {
		log.Println("No .env file found")
	}

	dsn := os.Getenv("DSN")
	db := database.ConnectMySQL(dsn)
	database.Migrate(db)
	fmt.Println("migrated successfully.")

	h := &controller.Handlers{DB: db}
	r := gin.Default()

	router.SetupRoutes(r, h)

	if err := r.Run(":3003"); err != nil {
		log.Fatal(err)
	}
}

ルーターセットアップ関数内では、ルーティング関数として「Handlers」構造体のレシーバ関数を呼び出します。これでコントローラー関数でデータベース操作用のインスタンスを参照できる様になりました。

func SetupRoutes(r *gin.Engine, h *controller.Handlers) {
	api := r.Group("/api")
	{
		// ========== Campgrounds ==========
		camps := api.Group("/campgrounds")
		{
			camps.GET("", h.CampgroundsIndex) // /campgrounds
			camps.POST("", middleware.AuthRequired(), h.CreateCampground)

			byID := camps.Group("/:id") // /campgrounds/:id ...
			{
				byID.PUT("", middleware.IsAuthor(), h.UpdateCampground)
				byID.DELETE("", middleware.IsAuthor(), h.DeleteCampground)
			}
		}

		// ========== Reviews ==========
		// Reviews は Campgrounds 配下の最長一致 (/campgrounds/:id/reviews) をグループ化
		reviews := api.Group("/campgrounds/:id/reviews")
		{
			reviews.POST("", middleware.AuthRequired(), h.CreateReview)
			reviews.DELETE("/:review_id", middleware.IsReviewAuthor(), h.DeleteReview)
		}

		// ========== Users ==========
		users := api.Group("/") // ルート直下で Users をまとめる
		{
			users.POST("/login", middleware.GuestOnly(), h.Login)
			users.POST("/register", h.Register)
			users.GET("/logout", h.Logout)
		}
	}
}
ログイン(認証) 機能を実装します

Go環境上にログイン機能を実装していきます。以下の仕様で作成します。

  • 認証は署名付きセッションクッキーで行う
  • CSRF対策のためのミドルウェアも実装する

まずは、プロンプトを駆使してChatGPTにコードを生成してもらいます。以下の様に質問しました。(途中は省略してあります。)

Go + GORM + Gin という構成で REST API サーバーを作成しています。 
以下の情報からクッキーを使用したログイン機構のコードを作成することは可能ですか? 

【コントローラー】※この関数に追加したい 
// POST /login
func (h *Handlers) Login(c *gin.Context) {
	var u models.User
	if err := c.ShouldBindJSON(&u); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// 認証処理の例: 成功したと仮定
	c.JSON(http.StatusOK, gin.H{"message": "logged in", "user": u.UserName})
}

【Handlers】
type Handlers struct {
	DB *gorm.DB
}

【モデル】※この関数群でデータベース(MySQL)を操作します 
package models

import (
	"errors"

	"gorm.io/gorm"
)

// User テーブル
type User struct {
	UserName string `gorm:"primaryKey;size:191;not null;uniqueIndex"` // ユニークキー
	Email    string `gorm:"size:255;not null"`
	Password string `gorm:"size:255;not null"` // ハッシュを保存
}

中略
・・・

【Usersコントローラー全体】 
package controller

import (
	"net/http"
	"yelpcampreact/models"

	"github.com/gin-gonic/gin"
	"golang.org/x/crypto/bcrypt"
)

// POST /login
func (h *Handlers) Login(c *gin.Context) {
	var u models.User
	if err := c.ShouldBindJSON(&u); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// 認証処理の例: 成功したと仮定
	c.JSON(http.StatusOK, gin.H{"message": "logged in", "user": u.UserName})
}

中略
・・・

【ログイン手順】 
・Jsonで送られてきたデータから、ユーザー名でデータベース検索 
・送られてきたパスワードをハッシュ化して、保存されているパスワードと照合 
・パスワードが一致したらクッキー認証でログイン 
・フロントエンドでNext.jsのリバースプロキシを使用するのでCORSは使用しません。 
・セキュリティのため CSRF 機能も実装したい

以上の機能を持つサンプルコードを生成することは可能ですか?

約12秒で以下のセッション操作に必要なコードが生成されました。(そのままではエラーが出たため少し手直ししてあります。)

package controller

import (
	"crypto/rand"
	"encoding/base64"
	"errors"
	"fmt"
	"net/http"
	"os"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/gorilla/securecookie"
	"gorm.io/gorm"
)

const (
	sessionCookieName = "session"
	csrfCookieName    = "csrf_token"
	// SameSite=Lax なので GET での遷移では送信可。フォームやXHRはフロントからヘッダーを付ければOK
)

type Handlers struct {
	DB *gorm.DB
	sc *securecookie.SecureCookie
	// 任意: クッキーの有効期限
	SessionTTL time.Duration
}

func decodeMaybeBase64(s string) ([]byte, error) {
	// 標準の base64 を試す
	if b, err := base64.StdEncoding.DecodeString(s); err == nil {
		return b, nil
	}
	// padding なしの base64 も試す
	if b, err := base64.RawStdEncoding.DecodeString(s); err == nil {
		return b, nil
	}
	// どちらでもなければ「そのまま文字列をバイト列として扱う」
	return []byte(s), nil
}

// NewHandlers は初期化ヘルパー
func NewHandlers(db *gorm.DB) (*Handlers, error) {
	// --- HASH KEY(署名用)---
	hkEnv := os.Getenv("COOKIE_HASH_KEY")
	if hkEnv == "" {
		return nil, errors.New("COOKIE_HASH_KEY is required")
	}
	hashKey, err := decodeMaybeBase64(hkEnv)
	if err != nil {
		return nil, fmt.Errorf("invalid COOKIE_HASH_KEY: %w", err)
	}
	if len(hashKey) < 32 {
		return nil, fmt.Errorf("COOKIE_HASH_KEY should be >= 32 bytes, got %d", len(hashKey))
	}

	// --- BLOCK KEY(暗号化用・任意)---
	var blockKey []byte
	if bkEnv := os.Getenv("COOKIE_BLOCK_KEY"); bkEnv != "" {
		b, err := decodeMaybeBase64(bkEnv)
		if err != nil {
			return nil, fmt.Errorf("invalid COOKIE_BLOCK_KEY: %w", err)
		}
		if l := len(b); l != 16 && l != 24 && l != 32 {
			return nil, fmt.Errorf("COOKIE_BLOCK_KEY must be 16/24/32 bytes after decoding, got %d", l)
		}
		blockKey = b
	}

	var sc *securecookie.SecureCookie
	if len(blockKey) > 0 {
		sc = securecookie.New(hashKey, blockKey) // 署名 + 暗号化
	} else {
		sc = securecookie.New(hashKey, nil) // 署名のみ
	}
	sc.MaxAge(int((24 * time.Hour).Seconds()))

	return &Handlers{
		DB:         db,
		sc:         sc,
		SessionTTL: 24 * time.Hour,
	}, nil
}

// -------- CSRF(ダブルサブミット) --------

// CSRFMiddleware は非安全メソッド時にヘッダとクッキーの一致を確認
func CSRFMiddleware() gin.HandlerFunc {
	// 除外パス(prefixマッチでもOK)
	skip := map[string]struct{}{
		"/api/login":    {},
		"/api/register": {},
	}

	return func(c *gin.Context) {
		// 除外
		if _, ok := skip[c.FullPath()]; ok {
			c.Next()
			return
		}

		switch c.Request.Method {
		case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
			h := c.GetHeader("X-CSRF-Token")
			csrfCookie, err := c.Cookie(csrfCookieName)
			if err != nil || csrfCookie == "" || h == "" || !secureCompare(h, csrfCookie) {
				c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid csrf token"})
				return
			}
		}
		c.Next()
	}
}

// secureCompare は時間依存しない比較(短い定数時間)
// 簡易実装:長さが違ったら false、同じなら全バイト比較
func secureCompare(a, b string) bool {
	if len(a) != len(b) {
		return false
	}
	var v byte
	for i := 0; i < len(a); i++ {
		v |= a[i] ^ b[i]
	}
	return v == 0
}

func newRandomToken(n int) (string, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

// -------- セッション(署名クッキー) --------

type sessionPayload struct {
	Sub string    `json:"sub"` // username
	Exp time.Time `json:"exp"`
}

func (h *Handlers) setSessionCookie(c *gin.Context, payload sessionPayload) error {
	if h.sc == nil {
		return errors.New("securecookie is not initialized; use controller.NewHandlers to construct Handlers")
	}
	value := map[string]string{
		"sub": payload.Sub,
		"exp": payload.Exp.UTC().Format(time.RFC3339),
	}
	encoded, err := h.sc.Encode(sessionCookieName, value)
	if err != nil {
		return err
	}
	http.SetCookie(c.Writer, &http.Cookie{
		Name:     sessionCookieName,
		Value:    encoded,
		Path:     "/",
		MaxAge:   int(time.Until(payload.Exp).Seconds()),
		HttpOnly: true,
		Secure:   true, // 本番は必ず true(HTTPS 前提)
		SameSite: http.SameSiteLaxMode,
	})
	return nil
}

func (h *Handlers) clearSessionCookie(c *gin.Context) {
	http.SetCookie(c.Writer, &http.Cookie{
		Name:     sessionCookieName,
		Value:    "",
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
	})
}

func (h *Handlers) parseSession(c *gin.Context) (*sessionPayload, error) {
	raw, err := c.Cookie(sessionCookieName)
	if err != nil {
		return nil, errors.New("no session")
	}
	value := map[string]string{}
	if err := h.sc.Decode(sessionCookieName, raw, &value); err != nil {
		return nil, errors.New("invalid session signature")
	}
	exp, err := time.Parse(time.RFC3339, value["exp"])
	if err != nil || time.Now().After(exp) {
		return nil, errors.New("session expired")
	}
	return &sessionPayload{
		Sub: value["sub"],
		Exp: exp,
	}, nil
}

// AuthRequired は保護ルート用(有効なセッションのみ許可)
func (h *Handlers) AuthRequired() gin.HandlerFunc {
	return func(c *gin.Context) {
		sess, err := h.parseSession(c)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
			return
		}
		// コンテキストへ格納(必要なら後続で利用)
		c.Set("userName", sess.Sub)
		c.Next()
	}
}

// 未ログインのみ許可(ログイン済みなら 403)
func (h *Handlers) GuestOnly() gin.HandlerFunc {
	return func(c *gin.Context) {
		if _, err := h.parseSession(c); err == nil {
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "already logged in"})
			return
		}
		c.Next()
	}
}

// -------- CSRF クッキー設定 --------

func (h *Handlers) setCSRFCookie(c *gin.Context) (string, error) {
	token, err := newRandomToken(32)
	if err != nil {
		return "", err
	}
	// ダブルサブミットなので HttpOnly=false(フロントでヘッダーに載せる)
	http.SetCookie(c.Writer, &http.Cookie{
		Name:     csrfCookieName,
		Value:    token,
		Path:     "/",
		MaxAge:   int(h.SessionTTL.Seconds()),
		HttpOnly: false,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
	})
	return token, nil
}

func (h *Handlers) clearCSRFCookie(c *gin.Context) {
	http.SetCookie(c.Writer, &http.Cookie{
		Name:     csrfCookieName,
		Value:    "",
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: false,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
	})
}

目的のログインに必要な関数は以下の様に生成されました。

// POST /login
func (h *Handlers) Login(c *gin.Context) {
	// 受け取りスキーマ(モデル直バインドより明示的)
	type loginReq struct {
		UserName string `json:"userName" binding:"required"`
		Password string `json:"password" binding:"required"`
	}
	var in loginReq
	// var in models.User
	if err := c.ShouldBindJSON(&in); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "userName and password are required"})
		return
	}

	// ユーザー名で検索
	u, err := models.GetUserByUserName(h.DB, in.UserName)
	if err != nil {
		// 存在をぼかす
		c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
		return
	}

	// パスワード照合(送信されたプレーン vs 保存されたハッシュ)
	if bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(in.Password)) != nil {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
		return
	}

	// セッションクッキー作成
	exp := time.Now().Add(h.SessionTTL)
	if err := h.setSessionCookie(c, sessionPayload{Sub: u.UserName, Exp: exp}); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set session"})
		return
	}

	// CSRF トークン配布(クッキー): フロントは X-CSRF-Token ヘッダに載せて送る
	csrfToken, err := h.setCSRFCookie(c)
	if err != nil {
		// セッションは消す
		h.clearSessionCookie(c)
		c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set csrf token"})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"message":    "logged in",
		"user":       u.UserName,
		"csrfHeader": "X-CSRF-Token",
		// 初回のみ返してあげるとフロント実装が楽(以降はクッキーから読む)
		"csrfToken": csrfToken,
		"expiresAt": exp.UTC().Format(time.RFC3339),
	})
}

あとは指示に従い、動作に必要なパッケージをインストールしていきます。VSCodeで「Ctrl + @」と入力して、ターミナル画面を開いてクッキーの署名と暗号化に必要な以下のパッケージをインストールします。

go get -u github.com/gorilla/securecookie
go get -u golang.org/x/crypto/bcrypt

続いて「.env」ファイルに署名と暗号化(任意)に必要な、Base64でエンコードされた32バイトのキー(秘密鍵)を追加します。

DSN=yelpcampreact:password@tcp(localhost:3306)/yelpcampreact?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local
COOKIE_HASH_KEY='WK9GHT9Dth9ZZKaOhuoQy7JiHH7jP2BrnUvJvCFl8no='
COOKIE_BLOCK_KEY='OrcW2MdM6lOXQksIeEIVivqWBbppDyuLzljmQLHDI0E='

キーの生成はWSL2などで、以下のコマンドを実行すると取得できる様です。

openssl rand -base64 32

実装が完了したので、実際にブラウザーからログインできるか試してみます。特に問題ない様です。

CSRF対策用のクッキーも送られてきている様です。

認可機能を実装します

認可機能を実現するには、現在ログインしているかを確認する必要があります。認証情報(ユーザー名等)はセッションクッキー内に保存されていますが、セキュリティのためセッション用のクッキーは「HttpOnly」という属性がTrueになっています。このプロパティが有効になっている場合は、ブラウザー側でJavaScriptや「Document.cookie」プロパティからは参照できなくなる様です。(余談ですが、CSRF用のクッキーはこのプロパティがFalseになっているのでブラウザーから読めます。)

ではどうするかというと、「サーバーに聞いてみる」というのが王道の手法の様です。認証されていなければアクセスできないURL(API)に問い合わせて、正常に通信が終了すれば認証されているという判定を行う様です。今回のアプリではサーバー(Go)側に「/api/me」という新たなエンドポイントを追加しました。このURLへのアクセスは認証が必要なので、ログインしていない場合は「200 OK」以外のステータスコード(401等)が返ってきます。

func SetupRoutes(r *gin.Engine, h *controller.Handlers) {

	r.Use(controller.CSRFMiddleware())

	api := r.Group("/api")
	{
		// ログイン状態確認
		api.GET("/me", h.AuthRequired(), h.Me)

		// ========== Campgrounds ==========
		camps := api.Group("/campgrounds")
		{

この振る舞いを利用して、フロントエンド側に以下の処理を追加してログインの状態を検知します(6~11行目)。あとは得られた結果を元に表示を切り替えます(32行目以降)。

import Link from "next/link";
import { cookies } from "next/headers";

export default async function NavBar({ children }) {
    // ブラウザのCookieを取得して、そのままAPIに転送
    const cookieHeader = cookies().toString(); // "a=1; b=2" の形式
    const res = await fetch("http://localhost:3003/api/me", {
        cache: "no-store",
        headers: { Cookie: cookieHeader }, // ← 重要
    });
    const isLoggedIn = res.ok; // 200=ログイン中 / 401=未ログイン
    let user;
    if (isLoggedIn) {
        try { user = await res.json(); } catch { }
    }

    return (
        <nav className="navbar sticky-top navbar-expand-lg navbar-dark bg-dark">
            <div className="container-fluid">
                <a className="navbar-brand" href="/">YelpCamp</a>
                <button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavAltMarkup"
                    aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
                    <span className="navbar-toggler-icon"></span>
                </button>
                <div className="collapse navbar-collapse" id="navbarNavAltMarkup">
                    <div className="navbar-nav">
                        <a className="nav-link" href="/">ホーム</a>
                        <a className="nav-link" href="/campgrounds">キャンプ場</a>
                        <a className="nav-link" href="/campgrounds/new">新規登録</a>
                    </div>
                    <div className="navbar-nav ms-auto">
                        {!isLoggedIn ? (
                            <>
                                <a className="nav-link" href="/login">ログイン</a>
                                <a className="nav-link" href="/register">ユーザー登録</a>
                            </>
                        ) : (
                            <>
                                <a className="nav-link" href="/logout">ログアウト</a>
                            </>
                        )}
                    </div>
                </div>
            </div>
        </nav>
    );
}

すべてのページにこの処理を書くのは大変なので、「useContext」というフック関数を使用してすべてのコンポーネントでログイン状態を取得できる様に修正していきます。まずは「src\app\context\AuthContext.js」というファイルを作成して、以下の内容を記述します。

"use client";

import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import api from "@/lib/axiosClient";

const AuthContext = createContext(null);

export function AuthProvider({ children, initialUser = null }) {
    const [user, setUser] = useState(initialUser);     // { user: "alice" } or null
    const [loading, setLoading] = useState(!initialUser);
    const router = useRouter();

    // 起動時/マウント時にログイン状態を確認(SSRでinitialUserがあればスキップ可)
    useEffect(() => {
        if (initialUser) return;
        let aborted = false;
        (async () => {
            try {
                const res = await fetch("/api/me", { cache: "no-store", credentials: "include" });
                if (!aborted) setUser(res.ok ? await res.json() : null);
            } finally {
                if (!aborted) setLoading(false);
            }
        })();
        return () => { aborted = true; };
    }, [initialUser]);

    // ログイン:成功したら user を即反映
    const login = useCallback(async (payload /* {UserName, Password} */) => {
        const res = await api.post("/api/login", payload);
        // ログイン後の最新ユーザーを取得
        const me = await fetch("/api/me", { cache: "no-store", credentials: "include" });
        setUser(me.ok ? await me.json() : null);
        router.refresh(); // サーバーコンポーネントも更新(NavBarなど)
        return res;
    }, [router]);

    // ログアウト:成功したら user を null に
    const logout = useCallback(async () => {
        const res = await api.post("/api/logout");
        setUser(null);
        router.refresh();
        return res;
    }, [router]);

    const value = { user, isLoggedIn: !!user, loading, login, logout };
    return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
    const ctx = useContext(AuthContext);
    if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
    return ctx;
}

一見複雑そうですが、以下の様な構成になっています。

  • 20行目・・・サーバーにログイン状態を問い合わせます。このコンポーネントが評価される度に実行されます。
  • 30行目、40行目・・・ログインとログアウト関数です。これらの関数が呼ばれると「user」ステート変数の内容が変わるので、コンポーネントが再実行されます。
  • 48行目・・・このコンポーネントの本体です。すべての子コンポーネントから1行上にある「value」の内容を参照できます。
  • 51行目・・・カスタムフック関数です。詳しいエラー内容が表示されます。

一番上位にあるレイアウトファイル「src\app\layout.js」にこのコンポーネントを配置します。これですべてのコンポーネントでログイン状態が取得できる様になります。

import { cookies } from "next/headers";
import { AuthProvider } from "./context/AuthContext";

async function fetchInitialUser() {
  // ブラウザのCookieをバックエンドに転送
  const cookieStore = await cookies();
  const cookieHeader = cookieStore.toString();
  const res = await fetch("http://localhost:3003/api/me", {
    headers: { Cookie: cookieHeader },
    cache: "no-store",
  });
  return res.ok ? await res.json() : null;
}

export default async function RootLayout({ children }) {
  const initialUser = await fetchInitialUser();
  return (
    <html lang="ja">
      <head>
        <title>YelpCamp デモ</title>
      </head>
      <body className='d-flex flex-column vh-100'>
        <AuthProvider initialUser={initialUser}>
          {children}
        </AuthProvider>
      </body>
    </html>
  );
}

表示を変えたいコンポーネントでは以下の様に使用して、ログイン状態を判定できます。

"use client";

import { useAuth } from "@/app/context/AuthContext";

export default function NavBar() {
    const { isLoggedIn, loading } = useAuth();

    return (
        <nav className="navbar sticky-top navbar-expand-lg navbar-dark bg-dark">
            <div className="container-fluid">
                <a className="navbar-brand" href="/">YelpCamp</a>
                <button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavAltMarkup"
                    aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
                    <span className="navbar-toggler-icon"></span>
                </button>
                <div className="collapse navbar-collapse" id="navbarNavAltMarkup">
                    <div className="navbar-nav">
                        <a className="nav-link" href="/">ホーム</a>
                        <a className="nav-link" href="/campgrounds">キャンプ場</a>
                        <a className="nav-link" href="/campgrounds/new">新規登録</a>
                    </div>
                    <div className="navbar-nav ms-auto">
                        {loading ? (
                            <>
                                <span className="nav-link disabled">…</span>
                            </>
                        ) : !isLoggedIn ? (
                            <>
                                <a className="nav-link" href="/login">ログイン</a>
                                <a className="nav-link" href="/register">ユーザー登録</a>
                            </>
                        ) : (
                            <>
                                <a className="nav-link" href="/logout">ログアウト</a>
                            </>
                        )}
                    </div>
                </div>
            </div>
        </nav>
    );
}
移植作業メモ
・フラッシュメッセージを表示するには

以下の様なメッセージを表示できる機能をアプリに追加します。

まずはメッセージを保持するクラスを作成します。「src\lib\FlashMessageService.js」というファイルを作成して、以下の定義を追加します。基本的に「Map」と呼ばれる連想配列のラッパークラスですが、39行目から42行目で定義されている「subscribe」という関数で、コールバック関数を登録できる様になっています。何かメッセージが追加されると登録されたコールバック関数が呼び出されて表示を更新できる様になっています。

export const FlashTypes = {
  INFO: 'INFO',
  ERROR: 'ERROR',
  DEBUG: 'DEBUG',
  PRIMARY: 'PRIMARY',
}

class FlashMessageService {
  constructor() {
    this.store = new Map()
    this.listeners = new Set()
  }

  push(type, message) {
    const arr = this.store.get(type) ?? []
    arr.push(message)
    this.store.set(type, arr)
    this.emit()
  }

  pushMany(type, messages) {
    const arr = this.store.get(type) ?? []
    this.store.set(type, [...arr, ...messages])
    this.emit()
  }

  has(type) {
    const arr = this.store.get(type)
    return !!arr && arr.length > 0
  }

  pop(type) {
    const arr = this.store.get(type) ?? []
    this.store.set(type, []) // clear after pop
    return arr
  }

  // 変更を通知する関数を登録します
  subscribe(listener) {
    this.listeners.add(listener)
    return () => this.listeners.delete(listener) // 解除用のクリーンアップ関数を返します
  }

  // 登録されているすべての関数を呼び出して、変更を通知します
  emit() {
    for (const l of this.listeners) l()
  }
}

export const flashService = new FlashMessageService()

「src\components\FlashMessage.js」というファイルを作成して、以下の内容を書き込みます。20行目でこのコンポーネント内で定義されているコールバック用の関数(load)を設定しています。何かメッセージが追加されるとこのコンポーネント内のコールバック関数「load」が呼び出されて、「setMessages」ステート関数によりメッセージ用の変数「messages」が変更されて、コンポーネントが再描画されます。

'use client'

import { useEffect, useState } from 'react'
import { flashService, FlashTypes } from '@/lib/FlashMessageService'

export default function FlashMessage({ flashType = FlashTypes.PRIMARY }) {
  const [isVisible, setIsVisible] = useState(true)
  const [messages, setMessages] = useState([])

  useEffect(() => {
    const load = () => {
      if (flashService.has(flashType)) {
        const popped = flashService.pop(flashType)
        setMessages(popped)
        setIsVisible(true)
      }
    }

    load()
    const unsub = flashService.subscribe(load)
    return unsub // 終了時はコールバック関数を削除します
  }, [flashType])

  if (!isVisible || messages.length === 0) return null

  const color = (() => {
    switch (flashType) {
      case FlashTypes.INFO:
        return 'alert-success'
      case FlashTypes.ERROR:
        return 'alert-danger'
      case FlashTypes.DEBUG:
        return 'alert-secondary'
      default:
        return 'alert-primary'
    }
  })()

  return (
    <div className={`alert ${color} alert-dismissible fade show`} role="alert">
      {messages.map((m, i) => (
        <div key={i}>{m}</div>
      ))}
      <button
        onClick={() => setIsVisible(false)}
        type="button" className="btn-close" data-bs-dismiss="alert" aria-label="Close" />
    </div>
  )
}

すべてのタイプのメッセージを表示できる様に、このコンポーネントを集約したコンポーネントを作成します。

'use client'

import FlashMessage from '@/components/FlashMessage'
import { FlashTypes } from '@/lib/FlashMessageService'

export default function FlashMessages() {
    return (
        <div className="space-y-2">
            <FlashMessage flashType={FlashTypes.PRIMARY} />
            <FlashMessage flashType={FlashTypes.INFO} />
            <FlashMessage flashType={FlashTypes.ERROR} />
            <FlashMessage flashType={FlashTypes.DEBUG} />
        </div>
    )
}

後はすべてのサブコンポーネント用の共通レイアウトファイル「src\app(CommonLayout)\layout.js」にこのコンポーネントを配置すれば実装完了です。

import 'bootstrap/dist/css/bootstrap.min.css';
import 'mapbox-gl/dist/mapbox-gl.css';
import mapboxgl from 'mapbox-gl';
import '@/styles/app.css'

import BodyClass from '@/components/BodyClass';
import FlashMessages from '@/components/FlashMessages'
import Navbar from '@/components/NavBar';
import Footer from '@/components/Footer';
import Scripts from './scripts';

export default function SubLayout({ children }) {
  return (
    <>
      <BodyClass classes="d-flex flex-column vh-100" />

      <Navbar />
      <main className="container mt-5">
        <FlashMessages />
        {children}
      </main>
      <Footer />

      <Scripts />
    </>
  );
}

実際に使用するにはメッセージを登録したいページで、「FlashMessageService」をインポートして以下の様に「push」関数でメッセージを登録するとフラッシュメッセージが表示されます。

'use client';

import { flashService, FlashTypes } from '@/lib/FlashMessageService'

export default function Register() {

    const handleSubmit = async (e) => {
        e.preventDefault();
        flashService.push(FlashTypes.INFO, '登録しました');
    };

    return (
        <form onSubmit={handleSubmit} className="validated-form" noValidate>
            <div className="d-grid">
                <button className="btn btn-success">登録する</button>
            </div>
        </form>
    );
}

メッセージをセットした後に違うページにリダイレクトされても、移動した先のページでメッセージが表示されます。

・画像をアップロードするには

画像ファイルやPDFなどのバイナリーデータを送信するには、Jsonなどの文字列だけでは対応できないので、<form>タグとかでよく見かける「multipart/form-data」というコンテンツタイプを使用してホスト(サーバー)に送信する様です。簡単にご説明すると「boundary」と呼ばれる区切り文字(ランダムな文字列)によって、テキストやバイナリーデータを種類ごとに区切って、複数のデータをまとめてサーバーに送信できる仕組みの様です。サイズ制限はある様ですが、ほぼ何でも送信できるようです。

前置きが長くなりましたが、React (Nex.js) のコードで書くと以下の様になるようです。(実際に使用しているコードではありません。ChatGPTで生成しました。) このコードで5行目にある「FormData」が、マルチパート用のフォームデータになっています。(これは組み込みの定義になっているので、自分で何も定義する必要はありません。) あとはこのオブジェクトに「append」関数でデータを追加して、最終的に21行目にある axios の第2引数でサーバーに送信します。

import axios from 'axios';

// files: FileList or File[]
async function updateCampground(campId, payload, files, removeIds = []) {
  const fd = new FormData();
  fd.append('Title', payload.Title);
  fd.append('Price', String(payload.Price));
  fd.append('Description', payload.Description ?? '');
  fd.append('Location', payload.Location);

  // 新規画像(複数)
  for (const f of files) {
    fd.append('Images[]', f);
  }

  // 削除したい画像ID配列は JSON文字列 で
  if (removeIds.length > 0) {
    fd.append('RemoveIds', JSON.stringify(removeIds));
  }

  const res = await axios.put(`/api/campgrounds/${campId}`, fd, {
    headers: {
      'X-CSRF-Token': getCsrfFromCookie(), // ダブルサブミット
      // Content-Type は axios が自動で multipart にします。手動指定しないこと
    },
    withCredentials: true,
  });

  return res.data;
}

サーバー(Go)側では、以下の様に処理する様です。特に複雑な処理は必要ない様です。マルチパートのフォームデータからデータを取得して、ローカルファイルにコピーしています。(ファイルが複数ある場合は、もう少し複雑な処理になると思われます。)

package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
)

func uploadHandler(w http.ResponseWriter, r *http.Request) {
	// まず multipart/form-data をパース
	err := r.ParseMultipartForm(10 << 20) // 最大 10MB までメモリに保持
	if err != nil {
		http.Error(w, "ファイルの読み込みに失敗しました", http.StatusBadRequest)
		return
	}

	// フォームフィールド "username" を取得
	username := r.FormValue("username")
	fmt.Printf("username: %s\n", username)

	// フォームフィールド "file" のファイルを取得
	file, handler, err := r.FormFile("file")
	if err != nil {
		http.Error(w, "ファイルが見つかりません", http.StatusBadRequest)
		return
	}
	defer file.Close()

	fmt.Printf("アップロードされたファイル: %s (%d bytes)\n", handler.Filename, handler.Size)

	// 保存先を作成
	dst, err := os.Create("./uploads/" + handler.Filename)
	if err != nil {
		http.Error(w, "保存に失敗しました", http.StatusInternalServerError)
		return
	}
	defer dst.Close()

	// アップロードされた内容を保存先にコピー
	_, err = io.Copy(dst, file)
	if err != nil {
		http.Error(w, "保存に失敗しました", http.StatusInternalServerError)
		return
	}

	fmt.Fprintf(w, "アップロード成功!ユーザー: %s, ファイル: %s", username, handler.Filename)
}

func main() {
	http.HandleFunc("/upload", uploadHandler)

	fmt.Println("http://localhost:8080/upload で待ち受け中")
	http.ListenAndServe(":8080", nil)
}
・アップロードした画像を表示するには

Go(Gin)に定義したルーティング関数に、以下の行を追加します。この関数で、プロジェクト直下にある「./uploads」というフォルダ内のコンテンツを「localhost:3003/uploads/...」というURLで参照できる様になります。(ディレクトリ(サブフォルダ)の一覧表示はできない様なので、ファイル名まで指定しないと表示されません。)

func SetupRoutes(r *gin.Engine, h *controller.Handlers) {
	// ./uploads 配下を /uploads 配下で配信
	r.Static("/uploads", "./uploads")

	r.Use(controller.CSRFMiddleware())

	api := r.Group("/api")
	{

しかしこのままでは、フロント側で常に「localhost:3003/」を付ける必要があるので、フロント側の「next.config.mjs」ファイルに以下の設定を追加して、画像ファイルもリバースプロキシでアクセスできる様に設定します。この様に設定しておくとフロント側で「/uploads/<ファイル名>」というリンクで画像が表示されます。

/** @type {import('next').NextConfig} */
const nextConfig = {
  async rewrites() {
    return [
      {
        source: "/api/:path*",
        destination: "http://localhost:3003/api/:path*",
      },
      {
        source: "/uploads/:path*",
        destination: "http://localhost:3003/uploads/:path*",
      },
    ];
  }
};

export default nextConfig;
各プロジェクトをリリース(デプロイ)用にビルドします

ここでは、フロントエンド(React)側とバックエンド(Go)側のアプリをデプロイ可能なリリース版に変換して、起動する方法をご説明いたします。

・フロントエンド(React)側をビルドします

Ctrl + @と入力して、ターミナル画面を開いて以下のコマンドを実行します。Reactのプロジェクトフォルダ(frontend)内にある「.next」というフォルダにリリース用のビルド成果物が生成されます。

cd c:\MAMP\htdocs\YelpCampReact\frontend
npm run build

あとはWindowsのターミナルアプリ等で、Reactのプロジェクトフォルダ(frontend)まで移動してから「npm run start」と入力するとフロントエンド側のサーバープロセスが起動します。

C:\MAMP\htdocs\YelpCampReact\frontend>npm run start

> frontend@0.1.0 start
> next start -p 3000

   ▲ Next.js 15.4.5
   - Local:        http://localhost:3000
   - Network:      http://192.168.11.42:3000

 ✓ Starting...
 ✓ Ready in 475ms
・バックエンド(Go)側をビルドします

Ctrl + @と入力して、ターミナル画面を開いて以下のコマンドを実行します。以下の例では、Goのプロジェクトフォルダ(backend)内に「main.exe」という実行ファイルが生成されます。

cd c:\MAMP\htdocs\YelpCampReact\backend
go build -o main.exe main.go

あとはWindowsのターミナルアプリ等で、Goのプロジェクトフォルダ(backend)まで移動してから「main.exe」または「main」と入力するとバックエンド側のサーバープロセスが起動します。

C:\MAMP\htdocs\YelpCampReact\backend>main.exe
migrated successfully.
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /uploads/*filepath        --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (3 handlers)
[GIN-debug] HEAD   /uploads/*filepath        --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (3 handlers)
[GIN-debug] GET    /api/me                   --> yelpcampreact/controller.(*Handlers).Me-fm (5 handlers)
[GIN-debug] GET    /api/config               --> yelpcampreact/controller.(*Handlers).PublicConfig-fm (4 handlers)
[GIN-debug] POST   /api/seeds                --> yelpcampreact/controller.(*Handlers).SeedDB-fm (4 handlers)
[GIN-debug] GET    /api/campgrounds          --> yelpcampreact/controller.(*Handlers).CampgroundsIndex-fm (4 handlers)
[GIN-debug] POST   /api/campgrounds          --> yelpcampreact/controller.(*Handlers).CreateCampgroundMultipart-fm (5 handlers)
[GIN-debug] GET    /api/campgrounds/:id      --> yelpcampreact/controller.(*Handlers).ShowCampground-fm (4 handlers)
[GIN-debug] PUT    /api/campgrounds/:id      --> yelpcampreact/controller.(*Handlers).UpdateCampgroundMultipart-fm (6 handlers)
[GIN-debug] DELETE /api/campgrounds/:id      --> yelpcampreact/controller.(*Handlers).DeleteCampground-fm (6 handlers)
[GIN-debug] GET    /api/campgrounds/:id/reviews --> yelpcampreact/controller.(*Handlers).ListReviews-fm (4 handlers)
[GIN-debug] POST   /api/campgrounds/:id/reviews --> yelpcampreact/controller.(*Handlers).CreateReview-fm (5 handlers)
[GIN-debug] DELETE /api/campgrounds/:id/reviews/:review_id --> yelpcampreact/controller.(*Handlers).DeleteReview-fm (6 handlers)
[GIN-debug] POST   /api/login                --> yelpcampreact/controller.(*Handlers).Login-fm (5 handlers)
[GIN-debug] POST   /api/register             --> yelpcampreact/controller.(*Handlers).Register-fm (4 handlers)
[GIN-debug] POST   /api/logout               --> yelpcampreact/controller.(*Handlers).Logout-fm (5 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :3003

デフォルトの状態では「デバッグモード」で起動するので、DOSのコマンドプロンプトで実行モードを切り替えるには以下の様に入力します。

set GIN_MODE=release
main.exe

PowerShellの場合は以下の様に入力します。

$env:GIN_MODE="release"
.\main

常にリリースモードで起動したい場合は、メイン関数で以下の様に設定するようです。

import "github.com/gin-gonic/gin"

func main() {
    gin.SetMode(gin.ReleaseMode) // ← ここで明示的に設定
    r := gin.Default()
    // ルーティングなど
    r.Run()
}
・サブディレクトリで動作させるには

「localhost:3000」ではなくて、「<サーバー名>/YelpCampReact」等のサブディレクトリ形式でアクセスしたい場合は、リバースプロキシ設定が必要です。この設定を行うと「/YelpCampReact」へのアクセスをすべて「localhost:3000」に中継してくれる様になります。

MAMPで設定を行うには、「C:\MAMP\conf\apache\httpd.conf」ファイルを開いて、ファイルの最後の方に以下の設定を追加します。その後MAMPのメイン画面にある電源ボタン(Stop Servers)を2回押してサーバーを再起動するとリバースプロキシが有効になります。

<IfModule mod_proxy.c>
    ProxyRequests Off
    ProxyPreserveHost On

    RequestHeader set X-Forwarded-Host "%{HTTP_HOST}s"
    RequestHeader set X-Forwarded-Proto "http"

    # HMR (WebSocket) – dev中に必要(ホットリロード用)。必ず ws:// で!
    ProxyPass        /YelpCampReact/_next/webpack-hmr  ws://127.0.0.1:3000/YelpCampReact/_next/webpack-hmr
    ProxyPassReverse /YelpCampReact/_next/webpack-hmr  ws://127.0.0.1:3000/YelpCampReact/_next/webpack-hmr

    # Next の静的など(具体的を先に)
    ProxyPass        /YelpCampReact/_next               http://127.0.0.1:3000/YelpCampReact/_next
    ProxyPassReverse /YelpCampReact/_next               http://127.0.0.1:3000/YelpCampReact/_next

    # アプリ本体(/YelpCampReact 配下を“剥がさず”丸ごと Next へ)
    ProxyPass        /YelpCampReact                     http://127.0.0.1:3000/YelpCampReact
    ProxyPassReverse /YelpCampReact                     http://127.0.0.1:3000/YelpCampReact
</IfModule>
サブディレクトリで動作する様にアプリを修正します

React(Next.js)アプリをサブディレクトリで動作させるには、まず「frontend\.env.local」というファイルを作成して以下の設定を追加します。サブディレクトリ名の先頭には「/」を付けて、終端には「/」を付けない様にします。

NEXT_PUBLIC_BASE_PATH=/YelpCampReact

「frontend\next.config.mjs」設定ファイルに「basePath」という項目を追加して、「.env.local」ファイルの設定内容を参照できる様にします。この設定を追加しておくと「router.push("/campgrounds");」などのリダイレクト時に自動的にサブフォルダ名をパスに追加してくれます。

/** @type {import('next').NextConfig} */
const nextConfig = {
  basePath: process.env.NEXT_PUBLIC_BASE_PATH,
  async rewrites() {
    return [
      {
        source: "/api/:path*",
        destination: "http://localhost:3003/api/:path*",
      },
      {
        source: "/uploads/:path*",
        destination: "http://localhost:3003/uploads/:path*",
      },
    ];
  }
};

export default nextConfig;

ヘルパー関数も作成しておくと便利かもしれません。

const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '';

export const baseUrl = (p = '') =>
  `${basePath}${p.startsWith('/') ? p : `/${p}`}`;

export const apiUrl = (p = '') =>
  `${basePath}/api${p.startsWith('/') ? p : `/${p}`}`;

export const uploadsUrl = (p = '') =>
  `${basePath}/uploads${p.startsWith('/') ? p : `/${p}`}`;

画面の遷移が必要な個所に適用しておきます。

'use client';

import { baseUrl } from "@/lib/Url";
import { useAuth } from "@/app/context/AuthContext";

export default function MainNavBar() {
    const { isLoggedIn, loading } = useAuth();

    return (
        <header className="mb-auto">
            <div>
                <h3 className="float-md-start mb-0">YelpCamp</h3>
                <nav className="nav nav-masthead justify-content-center float-md-end">
                    <a href={baseUrl("/")} className="nav-link active">ホーム</a>
                    <a href={baseUrl("campgrounds")} className="nav-link">キャンプ場</a>
                    {loading ? (
                        <>
                            <span className="nav-link disabled">…</span>
                        </>
                    ) : !isLoggedIn ? (
                        <>
                            <a href={baseUrl("login")} className="nav-link">ログイン</a>
                            <a href={baseUrl("register")} className="nav-link">ユーザー登録</a>
                        </>
                    ) : (
                        <>
                            <a href={baseUrl("logout")} className="nav-link">ログアウト</a>
                        </>
                    )}
                </nav>
            </div>
        </header>
    );
}
アプリを実行するには

今回のソースコードはこちらからダウンロードできます。「.env」や「.env.local」ファイル内に地図の表示に必要な「MAPBOX_TOKEN」キーは記述していないのでご自身で設定してください。フロント側の環境ファイル(.env.local)にも「NEXT_PUBLIC_MAPBOX_TOKEN」というキーが必要ですが同じキーを設定すれば動作します。MapboxのAPIキーの取得方法はこちらのサイト様等をご参照ください。

データベースはこちらのセクションを参考にして手動で作成してください。各データテーブルはバックエンド(Go)アプリを起動すると自動的に作成されます。

アプリを実行するには、まず最初にVSCodeに「Ctrl + @」と入力して、表示されたターミナル画面に以下のコマンドを入力して、必要なパッケージをインストールします。

cd c:\MAMP\htdocs\YelpCampReact\frontend
npm i

パッケージのインストールが終了したら、「実行とデバッグ」画面に移動してドロップダウンメニューから「Debug All」項目を選択してデバッガーを起動します。

途中以下の様な画面が表示されたら、「許可」を選択します。

ブラウザーを起動してURL欄に「localhost:3000」と入力するとメインページが表示されます。

初期状態では「localhost:3000」で待ち受ける様になっています。リバースプロキシをご使用になる場合は、「frontend\.env.local」ファイルを開いて「NEXT_PUBLIC_BASE_PATH」定数のコメントアウトをはずして、サブフォルダ名を設定してください。(サブフォルダの末尾には「/」を付けないでください。)

NEXT_PUBLIC_MAPBOX_TOKEN='pk.eyJ....'
NEXT_PUBLIC_BASE_PATH=/YelpCampReact

「localhost:3000/seeds」(リバースプロキシ使用時は「localhost/YelpCampReact/seeds」)にアクセスするとダミーデータが作成できます。

初期ユーザー名は「test」、パスワードは「password」になっています。このユーザーが所有するダミーのキャンプ場が50件作成されます。

まとめ

今回はかなりの部分をChatGPTに助けてもらいました。バックエンドのGoプログラムはほぼすべてChatGPTで生成したものになっています。自動生成とはいえ、CSRFにも対応しておりかなり堅牢なシステムになっています。すべて手作業で作成していたらもっと時間がかかったと思われます。これからのプログラミングは適切な文章を書く力が必要になるのかもしれません。

以上です。よろしかったらお試しください。

Blazor

前の記事

Blazor版 YelpCampデモ