Go言語でシンプルなWebアプリケーションを作る：ワークアウト記録アプリ


![スクリーンショット 2024-10-05 14.10.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3364428/839ed97c-12c2-4d19-b467-5012b6513933.png)

## はじめに

今回は、Go言語（Golang）を使用して、シンプルなワークアウト記録Webアプリケーションを作成してみました。この記事は、私がGo言語の基本を学ぶための備忘録として書いています。そのため間違っている部分や改善点など、あればコメントしていただけると幸いです。データベースにはPostgreSQLを使用し、Dockerを利用して環境を構築します。

## プロジェクト構造

まずは、プロジェクトの構造を見てみましょう：

```
/my-go-workout-app
  ├── Dockerfile
  ├── docker-compose.yml
  ├── go.mod
  ├── go.sum
  ├── init.sql
  ├── main.go
  ├── handlers
  │   └── records.go
  │   └── download_handler.go
  ├── models
  │   └── workout.go
  └── templates
      └── index.html
```

各ファイルの役割：
- `Dockerfile`: Goアプリケーションのコンテナ化のための指示書
- `docker-compose.yml`: アプリケーションとデータベースのコンテナを定義・管理
- `go.mod`, `go.sum`: Go言語の依存関係管理ファイル
- `init.sql`: データベースの初期化スクリプト
- `main.go`: アプリケーションのエントリーポイント
- `handlers/records.go`: HTTPリクエストを処理するハンドラ関数
- `models/workout.go`: データモデルの定義
- `templates/index.html`: Webページのテンプレート

## コード解説

### 1. go.mod

```go
module myapp

go 1.18

require (
    github.com/lib/pq v1.10.9
)
```

- `module myapp`: このプロジェクトのモジュール名を定義します。
- `go 1.18`: 使用するGo言語のバージョンを指定します。
- `require`: プロジェクトの依存関係を列挙します。ここでは、PostgreSQLドライバ `github.com/lib/pq` を使用しています。

### 2. main.go

```go
package main // パッケージ名を宣言。Goのプログラムはパッケージ単位で構成される。

import (
    "database/sql" // SQLデータベースとのやり取りを行うパッケージ
    "fmt"          // フォーマット処理を行うためのパッケージ（文字列や出力整形）
    "log"          // ログ出力を行うパッケージ
    "net/http"     // HTTPサーバーを構築するためのパッケージ
    "os"           // 環境変数などのOSとのやり取りを行うパッケージ
    "time"         // 時間関連の処理を行うパッケージ

    "myapp/handlers" 
    _ "github.com/lib/pq" // PostgreSQLドライバをインポート。明示的に使用しないため、名前を省略
)

func main() {
    // DSN（データソース名）を作成。データベース接続情報をフォーマットして環境変数から取得
    dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s sslmode=disable",
        os.Getenv("DB_HOST"),       
        os.Getenv("DB_USER"),      
        os.Getenv("DB_PASSWORD"),  
        os.Getenv("DB_NAME"))       

    var err error // エラーハンドリング用の変数を宣言
    for i := 0; i < 30; i++ { // データベース接続を最大30回試みるループ
        handlers.DB, err = sql.Open("postgres", dsn) // PostgreSQLデータベースに接続
        if err == nil { // 接続成功時の処理
            err = handlers.DB.Ping() 
            if err == nil { 
                break 
            }
        }
        log.Printf("データベース接続を試行中... (%d/30)", i+1)
        time.Sleep(2 * time.Second) 
    }
    if err != nil { // 最終的にエラーが発生した場合
        log.Fatalf("データベース接続に失敗しました: %v", err) // エラーメッセージを表示してプログラムを終了
    }
    defer handlers.DB.Close() // プログラム終了時にデータベース接続を閉じる

    log.Println("サーバーがポート8080で起動しています...") 
    http.HandleFunc("/", handlers.ShowRecords)     
    http.HandleFunc("/add", handlers.AddRecord)     
    http.HandleFunc("/download", handlers.DownloadCSV)
    if err := http.ListenAndServe(":8080", nil); err != nil { 
        log.Fatal(err) 
    }
}
```

解説：
- `import`: 必要なパッケージをインポートします。`_`で始まるインポートは、パッケージの初期化のみを行います（ここではPostgreSQLドライバの初期化）。
- `main()`: プログラムのエントリーポイントです。
- データベース接続：環境変数から接続情報を取得し、接続を確立します。接続に失敗した場合は30回まで再試行します。
- HTTPサーバーの設定：`http.HandleFunc()`で各パスに対するハンドラ関数を設定します。
- `http.ListenAndServe(":8080", nil)`: 8080ポートでHTTPサーバーを起動します。

### 3. handlers/records.go

```go
package handlers

import (
    "database/sql"       
    "fmt"               
    "log"            
    "net/http"     
    "text/template"     
    "time"              
    "myapp/models"       
)

var DB *sql.DB

var tmpl = template.Must(template.ParseFiles("/app/templates/index.html")) // テンプレートファイルをパースして読み込み

func ShowRecords(w http.ResponseWriter, r *http.Request) {
    // `:=`はGo特有の書き方で、型推論を用いて変数を宣言し、値を代入する際に使う。
    rows, err := DB.Query("SELECT id, exercise, weight, reps, sets, date FROM workout_records")
    if err != nil {
        log.Printf("クエリ実行エラー: %v", err)
        http.Error(w, "内部サーバーエラー", http.StatusInternalServerError)
        return 
    }
    defer rows.Close()
   
    var records []models.WorkoutRecord
    for rows.Next() {
        var record models.WorkoutRecord 
        	if err := rows.Scan(&record.ID, &record.Exercise, &record.Weight, &record.Reps, &record.Sets, &record.Date); err != nil {
            
            log.Printf("行のスキャンエラー: %v", err)
            continue // エラーがあってもスライスに追加せず、ループを継続する。
        }
        records = append(records, record) // 成功したらスライスに追加
    }

    if err := tmpl.Execute(w, records); err != nil {
        log.Printf("テンプレート実行エラー: %v", err)
        http.Error(w, "内部サーバーエラー", http.StatusInternalServerError)
    }
}

func AddRecord(w http.ResponseWriter, r *http.Request) {

    if r.Method != http.MethodPost {
        http.Redirect(w, r, "/", http.StatusSeeOther)
        return 
    }

    exercise := r.FormValue("exercise")
    reps := r.FormValue("reps")
    sets := r.FormValue("sets")
    weight := r.FormValue("weight")
    date := time.Now().Format("2006-01-02")

    if exercise == "" || weight == "" || reps == "" || sets == "" || date == "" {
        http.Error(w, "全ての項目を入力してください。", http.StatusBadRequest)
        return // フォームの値が空の場合、処理を終了する
    }

    _, err := DB.Exec("INSERT INTO workout_records (exercise, weight, reps, sets, date) VALUES ($1, $2, $3, $4, $5)", exercise, weight, reps, sets, date)
    if err != nil {
        log.Printf("レコード挿入エラー: %v", err)
        http.Error(w, "内部サーバーエラー", http.StatusInternalServerError)
        return
    }

    fmt.Fprint(w, "トレーニング記録が追加されました！")
}

```

解説：
- `ShowRecords`: データベースからワークアウト記録を取得し、HTMLテンプレートにデータを渡して表示します。
- `AddRecord`: フォームから送信されたデータを受け取り、新しいワークアウト記録をデータベースに追加します。
- エラーハンドリング：各ステップでエラーをチェックし、適切なHTTPステータスコードとエラーメッセージを返します。

### 4. models/workout.go

```go
package models

// WorkoutRecordは筋トレ記録の構造体です。
type WorkoutRecord struct {
	ID       int    // レコードID
	Exercise string // 種目（例: スクワット）
	Weight   int    // 重量（kg）
	Reps     int    // レップ数（回数）
	Sets     int    // セット数
	Date     string // 記録の日付
}
```

解説：
- `WorkoutRecord`: ワークアウト記録の構造体を定義します。この構造体はデータベースのテーブル構造と対応しています。
### 5. handlers/download_handler.go（データのダウンロード）
```go
package handlers

import (
    "encoding/csv"
    "net/http"
    "strconv"

    "myapp/models"
)

func DownloadCSV(w http.ResponseWriter, r *http.Request) {
    rows, err := DB.Query("SELECT id, exercise, weight, reps, sets, date FROM workout_records")
    if err != nil {
        http.Error(w, "データの取得に失敗しました", http.StatusInternalServerError)
        return
    }
    defer rows.Close()

    w.Header().Set("Content-Type", "text/csv")
    w.Header().Set("Content-Disposition", "attachment; filename=workout_records.csv")

    writer := csv.NewWriter(w)

    writer.Write([]string{"ID", "種目", "重量 (kg)", "回数", "セット数", "日付"})

    for rows.Next() {
        var record models.WorkoutRecord
        err := rows.Scan(&record.ID, &record.Exercise, &record.Weight, &record.Reps, &record.Sets, &record.Date)
        if err != nil {
            http.Error(w, "データの読み取りに失敗しました", http.StatusInternalServerError)
            return
        }

        writer.Write([]string{
            strconv.Itoa(record.ID),
            record.Exercise,
            strconv.Itoa(record.Weight),
            strconv.Itoa(record.Reps),
            strconv.Itoa(record.Sets),
            record.Date,
        })
    }

    writer.Flush()

    if err := writer.Error(); err != nil {
        http.Error(w, "CSVの書き込みに失敗しました", http.StatusInternalServerError)
        return
    }
}
```
解説：

- データベースからワークアウト記録を全て取得します。
- HTTPレスポンスヘッダーを設定し、ブラウザにCSVファイルとしてダウンロードするよう指示します。
- CSVライターを作成し、ヘッダー行を書き込みます。
- データベースから取得した各レコードをCSVの行として書き込みます。
- 最後に、バッファに残っているデータを書き込み、エラーチェックを行います。

このハンドラーを使用することで、ユーザーはワークアウト記録を簡単にCSVファイルとしてダウンロードできるようになります。エラーハンドリングも適切に行われており、データベースやCSV書き込みの問題が発生した場合にも適切なエラーメッセージが返されるようになっています。
### 6. templates/index.html

```html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>GoTrain-筋トレ記録</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            background-color: #f4f4f9;
            margin: 0;
            padding: 20px;
            text-align: center;
        }

        h1 {
            color: #333;
        }

        table {
            width: 80%;
            margin: 20px auto;
            border-collapse: collapse;
            background-color: #fff;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
        }

        th, td {
            padding: 12px 20px;
            border: 1px solid #ddd;
            text-align: center;
        }

        th {
            background-color: #007bff;
            color: #fff;
        }

        tr:nth-child(even) {
            background-color: #f2f2f2;
        }

        form {
            margin: 20px auto;
            padding: 20px;
            background-color: #fff;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
            width: 50%;
        }

        input[type="text"],
        input[type="number"] {
            width: 80%;
            padding: 10px;
            margin: 10px 0;
            border: 1px solid #ccc;
            border-radius: 4px;
        }

        button {
            background-color: #007bff;
            color: white;
            padding: 10px 20px;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }

        button:hover {
            background-color: #0056b3;
        }

        label {
            display: block;
            margin: 10px 0;
            text-align: left;
        }

        .download-link {
            display: inline-block;
            margin: 20px 0;
            padding: 10px 20px;
            background-color: #28a745;
            color: white;
            text-decoration: none;
            border-radius: 4px;
        }

        .download-link:hover {
            background-color: #218838;
        }
    </style>
</head>
<body>
    <h1>GoTrain-筋トレ記録</h1>
    <table>
        <tr>
            <th>ID</th>
            <th>種目</th>
            <th>重量 (kg)</th>
            <th>回数</th>
            <th>セット数</th>
            <th>日付</th>
        </tr>
        {{range .}}
        <tr>
            <td>{{.ID}}</td>
            <td>{{.Exercise}}</td>
            <td>{{.Weight}} kg</td>
            <td>{{.Reps}}</td>
            <td>{{.Sets}}</td>
            <td>{{.Date}}</td>
        </tr>
        {{end}}
    </table>

    <p>
        <a href="/download" class="download-link">CSVでダウンロード</a>
    </p>

    <h2>新しいトレーニング記録を追加</h2>
    <form action="/add" method="POST">
        <label for="exercise">種目:</label>
        <input type="text" name="exercise" id="exercise" required><br>
        <label for="weight">重量 (kg):</label>
        <input type="number" name="weight" id="weight" required><br>
        <label for="reps">回数:</label>
        <input type="number" name="reps" id="reps" required><br>
        <label for="sets">セット数:</label>
        <input type="number" name="sets" id="sets" required><br>
        <button type="submit">追加</button>
    </form>
</body>
</html>
```

解説：
- シンプルなHTMLテンプレートです。
- `{{range .}}`: Goのテンプレート言語を使用して、ワークアウト記録のリストをループ処理します。
- フォーム：新しいワークアウト記録を追加するためのフォームを提供します。

### 7. Dockerfile

```dockerfile
FROM golang:1.18-alpine

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
RUN go mod verify

COPY . .

RUN go build -o main .

CMD ["./main"]
```

解説：
- `FROM golang:1.18-alpine`: 軽量なAlpineLinuxベースのGo 1.18イメージを使用します。
- `WORKDIR /app`: 作業ディレクトリを設定します。
- 依存関係のダウンロードと検証を行います。
- ソースコードをコピーし、アプリケーションをビルドします。
- `CMD ["./main"]`: コンテナ起動時にアプリケーションを実行します。

### 8. docker-compose.yml

```yaml
version: '3'

services:
  db:
    image: postgres:13
    environment:
      - POSTGRES_DB=workoutdb
      - POSTGRES_USER=root
      - POSTGRES_PASSWORD=strongpassword
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "5432:5432"

  go-app:
    build: .
    depends_on:
      - db
    environment:
      - DB_HOST=db
      - DB_NAME=workoutdb
      - DB_USER=root
      - DB_PASSWORD=strongpassword
    ports:
      - "8080:8080"

volumes:
  postgres_data:
```

解説：
- `db`: PostgreSQLデータベースサービスを定義します。
- `go-app`: Goアプリケーションサービスを定義します。
- 環境変数：データベース接続情報を環境変数として設定します。
- `depends_on`: データベースサービスが起動してからアプリケーションを起動するように設定します。

### 9. init.sql

```sql
CREATE TABLE IF NOT EXISTS workout_records (
    id SERIAL PRIMARY KEY,
    exercise VARCHAR(255) NOT NULL,
    weight INT NOT NULL,
    reps INT NOT NULL,
    sets INT NOT NULL,
    date DATE NOT NULL
);

INSERT INTO workout_records (exercise, weight, reps, sets, date) VALUES
    ('Push-ups', 20, 3, 3, '2024-10-01'),
    ('Squats', 15, 4, 4, '2024-10-02'),
    ('Pull-ups', 10, 3, 3, '2024-10-03');
```

解説：
- テーブルの作成：`workout_records`テーブルを作成します。
- 初期データの挿入：サンプルデータを挿入します。

## アプリケーションの実行

1. 依存関係の更新:
   ```
   go mod tidy
   ```
   このコマンドは、`go.mod`ファイルを読み取り、必要な依存関係をダウンロードし、`go.sum`ファイルを更新します。

2. Dockerコンテナのビルドと起動:
   ```
   docker-compose up --build
   ```
   このコマンドは、Dockerイメージをビルドし、コンテナを起動します。

3. ブラウザで `http://localhost:8080` にアクセスして、アプリケーションを使用します。
![スクリーンショット 2024-10-06 16.42.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3364428/921f2d65-4630-29cd-1ee9-403e0697d1ac.png)
![スクリーンショット 2024-10-06 16.44.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3364428/d0333a65-a0cf-54e3-acb8-b0c1a6a85d5b.png)

## まとめ

今回は、Go言語を使用してシンプルなワークアウト記録Webアプリケーションを作成してました。Go初心者としてデータベース操作、HTTPサーバーの設定、HTMLテンプレートの使用など、Webアプリケーション開発により基本的な要素を理解することに注力しました。
Goは強力で並列処理など効率的な言語です。このプロジェクトをベースに、さらに機能を追加したり、デザインを改善したりして、Goをより深く学んでいきたいと思います。