🕌

gin + gormでクリーンアーキテクチャなTodoアプリ Part1(Todo作成編)

2024/04/21に公開

はじめに

gin+gormで作成したTodoアプリを作成し、クリーンアーキテクチャに修正していく記事になります。

この記事は下記2つのPartで構成しています。

  • Part1: Todoアプリの作成
  • PArt2: クリーンアーキテクチャにリファクタリング

概要

この記事ではまず初めにgin + gormの基本的な使い方とCRUD処理をソースコードベースで説明します。
またhtmlの読み込み方法などを学び、このようなtodoアプリを作ることを目標としています。
手本

gin

Ginは、Go(Golang)で開発された高速なHTTP Webフレームワークです。
JSONバリデーション、レンダリングなど、WebアプリケーションやAPIの開発に役立つ多数の機能を提供してくれます。

初期構築

公式 クイックスタートを見て進めます。

1. ginのインストール

$ go get -u github.com/gin-gonic/gin

2. ginの立ち上げ

下記をgo runすると、localhost:8080で立ち上がります。

package main

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

func main() {
	r := gin.Default()
	r.GET("/ping", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "pong",
		})
	})
	r.Run() // 0.0.0.0:8080 でサーバーを立てます。
}

3.動作確認

下記のようにcurlでmessageが取得できれば、立ち上げ成功です。

% curl localhost:8080/ping
{"message":"pong"}

gorm

Gormは、Go言語(Golang)で最も人気のあるORM(Object-Relational Mapping)ライブラリの一つです。データベースとの間でデータをやり取りする際の操作を簡単に行うためのツールです。GormはSQLクエリを直接書く代わりに、より直感的なAPIを提供してくれます。

要はデータベースの操作が容易になり、コードの可読性と保守性を向上させてくれるものです。

初期構築

gormを使用する際には初期構築としては、下記の3点を行う必要があります。

  1. データベースの準備(mysql)
  2. データベーステーブルにマッピングする為のモデル構築
  3. データベースと接続

1. データベースの準備

こちらの構築方法については解いません。本記事では、mysqlをdocker-composeを使用して構築しております。もし、準備をされていない方は参考にしてDBを構築してもらえたらと思います。

設定ファイル

./docker-compose.yml
version: "3"

services:
  db:
    image: mysql
    container_name: db
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: go_todo
      TZ: "Asia/Tokyo"
    volumes:
      - ./db/my.cnf:/etc/mysql/conf.d/my.cnf
    ports:
      - 3306:3306
./db/my.cnf
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci

[client]
default-character-set=utf8mb4

動作確認

  • dockerコンテナの立ち上げ確認
詳細
  1. docker compose upでビルドが正常に実施できる
% docker compose up
[+] Running 1/1
 ✔ Container db  Created                                                                                    0.1s 
Attaching to db
db  | 2024-04-20 20:29:14+09:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.3.0-1.el8 started.
  1. コンテナのステータスがUpとなり、立ち上がっている。
% docker ps
CONTAINER ID   IMAGE     COMMAND                   CREATED         STATUS         PORTS                               NAMES
157b9653c1f6   mysql     "docker-entrypoint.s…"   8 seconds ago   Up 6 seconds   0.0.0.0:3306->3306/tcp, 33060/tcp   db
  1. コンテナに接続できる。
% docker exec -it 157b9653c1f6 bash
  • mysqlの動作確認
詳細

※ dockerコンテナに接続していること

  1. 下記の情報でmysqlに接続を行う。
    ユーザー名: root(mysqlの実行権限を持っているユーザーを指定)
    パスワード: password
bash-4.4# mysql -u root -p
Enter password: 
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 8
Server version: 8.3.0 MySQL Community Server - GPL

Copyright (c) 2000, 2024, Oracle and/or its affiliates.

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.
  1. データベースにgo_todoが存在すること。useでgo_todoデータベースが使用できることを確認
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| go_todo            |
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
5 rows in set (0.04 sec)

mysql> use go_todo
Database changed

2. モデルの構築

GORMは、一般的に使用されるフィールドを含むgorm.Modelという名前の事前定義された構造体を提供してくれます。
公式: 構築方法

GORMとDBドライバーのインストール

go get -u gorm.io/gorm

# sqlite
go get -u gorm.io/driver/sqlite

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

# postgres
go get -u "gorm.io/driver/postgres"

モデルの宣言

今回のTodoアプリでは下記のデータベーステーブルを使用します。

package models
import "gorm.io/gorm"
type Todo struct {
	*gorm.Model
	Content string `json:"content"`
}

gorm.Modelについて

GORMは、一般的に使用されるフィールドを含むgorm.Model という名前の事前定義された構造体を提供してくれています。
下記がgorm.Modelのフィールドです。

type Model struct {
  ID        uint           `gorm:"primaryKey"`
  CreatedAt time.Time
  UpdatedAt time.Time
  DeletedAt gorm.DeletedAt `gorm:"index"`
}

3. データベースとの接続

main.goを作成し、下記のようにコマンドを入力し、:8080でローカルホストを開くことができれば成功になります。

公式: 接続方法

% DB=go_todo DB_USER=root DB_PASSWORD=password DB_HOST=localhost DB_PORT=3306 go run .
main.go
package main

import (
	"fmt"
	"log"
	"os"
	"strconv"
	"github.com/gin-gonic/gin"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

type Todo struct {
	*gorm.Model
	Content string `json:"content"`
}


type DBConfig struct {
	User string
	Password string
	Host string
	Port int
	Table string
}

func getDBConfig() DBConfig {
    port, _ := strconv.Atoi(os.Getenv("DB_PORT"))
    return DBConfig{
        User: os.Getenv("DB_USER"),
        Password: os.Getenv("DB_PASSWORD"),
        Host: os.Getenv("DB_HOST"),
        Port: port,
		Table: os.Getenv("DB"),
    }
}



func connectionDB() (*gorm.DB, error) {
	config := getDBConfig();
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True", config.User, config.Password, config.Host, config.Port, config.Table)
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	return db, err
}


func main() {
	r := gin.Default()
	db, err := connectionDB()

	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}

	// Migrate the schema
	err = db.AutoMigrate(&Todo{})
	if err != nil {
		log.Fatalf("Failed to migrate database: %v", err)
	}

	r.GET("/ping", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "pong",
		})
	})

	fmt.Println("Database connection and setup successful")
	r.Run(":8080")
}
動作確認ログ
% DB=go_todo DB_USER=root DB_PASSWORD=password DB_HOST=localhost DB_PORT=3306 go run .
[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    /ping                     --> main.main.func1 (3 handlers)
Database connection and setup successful
[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 :8080

main.goの解説

  • DBConfig: DBの接続情報を保存する構造体モデル
DBConfig
type DBConfig struct {
	User string
	Password string
	Host string
	Port int
	Table string
}
  • getDBConfig(): DBの接続情報を環境変数から取り出し返す
getDBConfig()
func getDBConfig() DBConfig {
    port, _ := strconv.Atoi(os.Getenv("DB_PORT"))
    return DBConfig{
        User: os.Getenv("DB_USER"),
        Password: os.Getenv("DB_PASSWORD"),
        Host: os.Getenv("DB_HOST"),
        Port: port,
		Table: os.Getenv("DB"),
    }
}
  • connectionDB(): getDBConfig()で取得したDBの情報を用いて接続を行い、DBドライバーを返す。
connectionDB()
func connectionDB() (*gorm.DB, error) {
	config := getDBConfig();
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True", config.User, config.Password, config.Host, config.Port, config.Table)
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	return db, err
}
  • db.AutoMigrate(&Todo{}): モデル名の複数形名のテーブルを作成してくれる。main.goを実行すると、下記のようにtodosが作成されていることがわかる。
db.AutoMigrate()とテーブル追加の動作確認
  • db.AutoMigrate()
// Migrate the schema
err = db.AutoMigrate(&Todo{})
if err != nil {
	log.Fatalf("Failed to migrate database: %v", err)
}
  • テーブル追加の動作確認
mysql> use go_todo
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> show tables;
+-------------------+
| Tables_in_go_todo |
+-------------------+
| todos             |
+-------------------+
1 row in set (0.01 sec)

CRUDの処理

gin + gormを用いて、CRUDのREST APIを作成する。

  • CRUD処理のAPIを追加
  • CRUD処理の動作確認を行う。

APIを追加

下記のmain.goを使用する。
CRUD処理については、「listeners関数」に含まれている
ginを用いたREST APIの追加は公式: GET,POST,PUT,PATCH,DELETE,OPTIONS メソッドを使うを参照

main.go
package main

import (
	"fmt"
	"log"
	"os"
	"strconv"
	"github.com/gin-gonic/gin"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"net/http"
	"encoding/json"
)

type Todo struct {
	*gorm.Model
	Content string `json:"content"`
}


type DBConfig struct {
	User string
	Password string
	Host string
	Port int
	Table string
}

func getDBConfig() DBConfig {
    port, _ := strconv.Atoi(os.Getenv("DB_PORT"))
    return DBConfig{
        User: os.Getenv("DB_USER"),
        Password: os.Getenv("DB_PASSWORD"),
        Host: os.Getenv("DB_HOST"),
        Port: port,
		Table: os.Getenv("DB"),
    }
}

func connectionDB() (*gorm.DB, error) {
	config := getDBConfig();
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True", config.User, config.Password, config.Host, config.Port, config.Table)
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	return db, err
}

func errorDB(db *gorm.DB, c *gin.Context) bool {
	if db.Error != nil {
		log.Printf("Error todos: %v", db.Error)
		c.AbortWithStatus(http.StatusInternalServerError)
		return true // エラーがあったことを示す
	}
	return false // エラーがなかったことを示す
}

func listeners(r *gin.Engine, db *gorm.DB) {
	r.GET("/todo/delete", func(c *gin.Context) {
		id, _ := c.GetQuery("id")
		result := db.Delete(&Todo{}, id)
		if errorDB(result, c) { return }
	})

	r.POST("/todo/update", func(c *gin.Context) {
		id, _ := strconv.Atoi(c.PostForm("id"))
		content := c.PostForm("content")
		var todo Todo
		result := db.Where("id = ?", id).Take(&todo)
		if errorDB(result, c) { return }
		todo.Content = content
		result = db.Save(&todo)
		if errorDB(result, c) { return }
	})

	r.POST("/todo/create", func(c *gin.Context) {
		content := c.PostForm("content")
		fmt.Println(c.Request.PostForm, content)
		result := db.Create(&Todo{Content: content})
		if errorDB(result, c) { return }
	})

	r.GET("/todo/list", func(c *gin.Context) {
		var todos []Todo
		// Get all records
		result := db.Find(&todos)
		if errorDB(result, c) { return }
		fmt.Println(json.NewEncoder(os.Stdout).Encode(todos))
		c.JSON(http.StatusOK, todos)
	})

	r.GET("/todo/get", func(c *gin.Context) {
		var todo Todo
		id, _ := c.GetQuery("id")
		result := db.First(&todo, id)
		if errorDB(result, c) { return }
		// JSON形式でレスポンスを返す
		fmt.Println(json.NewEncoder(os.Stdout).Encode(todo))
		c.JSON(http.StatusOK, todo)
	})
}

func main() {
	r := gin.Default()
	db, err := connectionDB()

	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}

	// Migrate the schema
	err = db.AutoMigrate(&Todo{})
	if err != nil {
		log.Fatalf("Failed to migrate database: %v", err)
	}

	listeners(r, db)

	fmt.Println("Database connection and setup successful")
	r.Run(":8080")
}

各CRUD処理について

それぞれのAPIの挙動を確認する。
シェルから、curlでAPIを呼び出し、mysqlのデータの状態を確認していく、

CREATE

レコードの作成方法については公式: レコードの作成で確認することができます。
また、クエリや文字列のパラメータ取得については、こちらが参考になる。

main.goの下記の部分が作成している箇所となっています。

r.POST("/todo/create", func(c *gin.Context) {
    content := c.PostForm("content")
	result := db.Create(&Todo{Content: content})
	if errorDB(result, c) { return }
})

動作確認

/todo/createで正常にデータ作成が動作するか確認します。

確認方法
  • データ追加のスクリプト
% curl localhost:8080/todo/create -X POST -H "multipart/form-data" -F 'content=zenn_todo'
  • mysqlでデータが追加されていることを確認
mysql> select * from todos;
Empty set (0.01 sec)

mysql> select * from todos;
+----+-------------------------+-------------------------+------------+-----------+
| id | created_at              | updated_at              | deleted_at | content   |
+----+-------------------------+-------------------------+------------+-----------+
|  1 | 2024-04-20 13:56:03.090 | 2024-04-20 13:56:03.090 | NULL       | zenn_todo |
+----+-------------------------+-------------------------+------------+-----------+
1 row in set (0.00 sec)

READ

レコードの取得方法については公式: レコードの取得で確認することができます。

main.goの下記の部分が作成している箇所となっています。

r.GET("/todo/list", func(c *gin.Context) {
	var todos []Todo
	// Get all records
	result := db.Find(&todos)
	if errorDB(result, c) { return }
	fmt.Println(json.NewEncoder(os.Stdout).Encode(todos))
	c.JSON(http.StatusOK, todos)
})

r.GET("/todo/get", func(c *gin.Context) {
	var todo Todo
	id, _ := c.GetQuery("id")
	result := db.First(&todo, id)
	if errorDB(result, c) { return }
	// JSON形式でレスポンスを返す
	fmt.Println(json.NewEncoder(os.Stdout).Encode(todo))
	c.JSON(http.StatusOK, todo)
})

動作確認

/todo/listと/todo/getで正常にデータの読み取りが動作するか確認します。

確認方法
  • 一括取得
% curl localhost:8080/todo/list -X GET
[{"ID":1,"CreatedAt":"2024-04-20T13:56:03.09Z","UpdatedAt":"2024-04-20T17:36:30.119Z","DeletedAt":null,"content":"zenn_todo"},{"ID":2,"CreatedAt":"2024-04-20T14:07:28.673Z","UpdatedAt":"2024-04-20T14:07:28.673Z","DeletedAt":null,"content":"zenn_todo2"}]
  • 指定したIDのtodoを取得
% curl "localhost:8080/todo/get?id=2" -X GET

{"ID":2,"CreatedAt":"2024-04-20T14:07:28.673Z","UpdatedAt":"2024-04-20T14:07:28.673Z","DeletedAt":null,"content":"zenn_todo2"}

UPDATE

レコードの更新方法については公式: レコードの更新で確認することができます。

main.goの下記の部分が作成している箇所となっています。

r.POST("/todo/update", func(c *gin.Context) {
	id, _ := strconv.Atoi(c.PostForm("id"))
	content := c.PostForm("content")
	var todo Todo
	result := db.Where("id = ?", id).Take(&todo)
	if errorDB(result, c) { return }
	todo.Content = content
	result = db.Save(&todo)
	if errorDB(result, c) { return }
})

動作確認

/todo/updateで正常にデータが更新できるか確認します。

確認方法
  • データ更新と、更新したデータを取得するスクリプト
% curl localhost:8080/todo/update -X POST -H "multipart/form-data" -F "id=1" -F "content=zenn_todo_updated"

% curl "localhost:8080/todo/get?id=1"
{"ID":1,"CreatedAt":"2024-04-20T13:56:03.09Z","UpdatedAt":"2024-04-20T17:43:03.347Z","DeletedAt":null,"content":"zenn_todo_updated"}

DELETE

レコードの削除方法については公式: レコードの削除で確認することができます。

main.goの下記の部分が作成している箇所となっています。

r.GET("/todo/delete", func(c *gin.Context) {
	id, _ := c.GetQuery("id")
	result := db.Delete(&Todo{}, id)
	if errorDB(result, c) { return }
})

動作確認

/todo/deleteで正常にデータを削除できるか確認します。

確認方法
  • 削除した後に、listで確認する。ID=1が存在しないことがわかる。
% curl "http://localhost:8080/todo/destroy?id=1" -X GET

% curl localhost:8080/todo/list
[{"ID":2,"CreatedAt":"2024-04-20T14:07:28.673Z","UpdatedAt":"2024-04-20T14:07:28.673Z","DeletedAt":null,"content":"zenn_todo2"},{"ID":3,"CreatedAt":"2024-04-20T17:24:56.723Z","UpdatedAt":"2024-04-20T17:24:56.723Z","DeletedAt":null,"content":""}]

クライアント処理の追加

公式: HTML をレンダリングするあたりを参照しながら進めていく。

  1. main.goの変更
    • 更新等を行った際にリダイレクト処理を追加
    • ホームの/indexと編集ページの/editを追加
    • htmlファイルの読み込み処理を追加
  2. htmlファイルの準備

main.goの変更

main.go全文
package main

import (
	"fmt"
	"log"
	"os"
	"strconv"
	"github.com/gin-gonic/gin"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"net/http"
	"encoding/json"
)

type Todo struct {
	*gorm.Model
	Content string `json:"content"`
}


type DBConfig struct {
	User string
	Password string
	Host string
	Port int
	Table string
}

func getDBConfig() DBConfig {
    port, _ := strconv.Atoi(os.Getenv("DB_PORT"))
    return DBConfig{
        User: os.Getenv("DB_USER"),
        Password: os.Getenv("DB_PASSWORD"),
        Host: os.Getenv("DB_HOST"),
        Port: port,
		Table: os.Getenv("DB"),
    }
}

func connectionDB() (*gorm.DB, error) {
	config := getDBConfig();
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True", config.User, config.Password, config.Host, config.Port, config.Table)
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	return db, err
}

func errorDB(db *gorm.DB, c *gin.Context) bool {
	if db.Error != nil {
		log.Printf("Error todos: %v", db.Error)
		c.AbortWithStatus(http.StatusInternalServerError)
		return true // エラーがあったことを示す
	}
	return false // エラーがなかったことを示す
}

func listeners(r *gin.Engine, db *gorm.DB) {
	r.GET("/todo/delete", func(c *gin.Context) {
		id, _ := c.GetQuery("id")
		result := db.Delete(&Todo{}, id)
		if errorDB(result, c) { return }
		c.Redirect(http.StatusMovedPermanently, "/index")
	})

	r.POST("/todo/update", func(c *gin.Context) {
		id, _ := strconv.Atoi(c.PostForm("id"))
		content := c.PostForm("content")
		var todo Todo
		result := db.Where("id = ?", id).Take(&todo)
		if errorDB(result, c) { return }
		todo.Content = content
		result = db.Save(&todo)
		if errorDB(result, c) { return }
		c.Redirect(http.StatusMovedPermanently, "/index")
	})

	r.POST("/todo/create", func(c *gin.Context) {
		content := c.PostForm("content")
		fmt.Println(c.Request.PostForm, content)
		result := db.Create(&Todo{Content: content})
		if errorDB(result, c) { return }
		c.Redirect(http.StatusMovedPermanently, "/index")
	})

	r.GET("/todo/list", func(c *gin.Context) {
		var todos []Todo
		// Get all records
		result := db.Find(&todos)
		if errorDB(result, c) { return }
		fmt.Println(json.NewEncoder(os.Stdout).Encode(todos))
		c.JSON(http.StatusOK, todos)
	})

	r.GET("/todo/get", func(c *gin.Context) {
		var todo Todo
		id, _ := c.GetQuery("id")
		result := db.First(&todo, id)
		if errorDB(result, c) { return }
		// JSON形式でレスポンスを返す
		fmt.Println(json.NewEncoder(os.Stdout).Encode(todo))
		c.JSON(http.StatusOK, todo)
	})

	r.GET("/index", func(c *gin.Context) {
		var todos []Todo
		result := db.Find(&todos)
		if errorDB(result, c) { return }
		c.HTML(http.StatusOK, "index.html", gin.H{
			"title": "やることリスト",
			"todos": todos,
		})
	})

	//todo edit
	r.GET("/edit", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Query("id"))
		if err != nil {
			log.Fatalln(err)
		}
		var todo Todo
		db.Where("id = ?", id).Take(&todo)
		c.HTML(http.StatusOK, "edit.html", gin.H{
			"title": "Todoの編集",
			"todo":  todo,
		})
	})
}

func main() {
	r := gin.Default()
	db, err := connectionDB()
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}

	// Migrate the schema
	err = db.AutoMigrate(&Todo{})
	if err != nil {
		log.Fatalf("Failed to migrate database: %v", err)
	}
	r.LoadHTMLGlob("client/*")
	listeners(r, db)

	fmt.Println("Database connection and setup successful")
	r.Run(":8080")
}

解説

  1. LoadHTMLGlobを行うことで、指定のパスのhtml(今回はclinet/*.html)を全て読み込んでいる
r.LoadHTMLGlob("client/*")
  1. ホームページと編集ページの追加
    c.HTML(http.StatusOK, htmlファイル名, ...により、LoadHTMLGlobで読み込んだhtmlファイルを指定することができる。
ソースコード
	r.GET("/index", func(c *gin.Context) {
		var todos []Todo
		result := db.Find(&todos)
		if errorDB(result, c) { return }
		c.HTML(http.StatusOK, "index.html", gin.H{
			"title": "やることリスト",
			"todos": todos,
		})
	})

	//todo edit
	r.GET("/edit", func(c *gin.Context) {
		id, err := strconv.Atoi(c.Query("id"))
		if err != nil {
			log.Fatalln(err)
		}
		var todo Todo
		db.Where("id = ?", id).Take(&todo)
		c.HTML(http.StatusOK, "edit.html", gin.H{
			"title": "Todoの編集",
			"todo":  todo,
		})
	})
  1. /create, /delete, /updateにはホームページへのリダイレクト処理を追加
c.Redirect(http.StatusMovedPermanently, "/index")

htmlファイル

client/index.html 全文
<html>
    <body>
        <h1 class="page-title">
            {{ .title }}
        </h1>

        <form class="todo-form" action="/todo/create" method="POST">
            <div class="form-header">todo作成</div>
            <div class="form-body">
                <textarea class="todo-textarea" name="content" id="content" placeholder="Todoを追加" rows="4"></textarea>
                <button class="submit-button" type="submit">作成</button>
            </div>
        </form>

        <hr class="separator" />

        {{ range .todos }}
            <div class="todo-item">
                <p class="todo-content"><a class="edit-link" href="/edit?id={{.ID}}">{{ .ID }}:{{ .Content }}</a></p>
                <a class="delete-link" href="/todo/delete?id={{ .ID }}">[削除]</a>
            </div>
        {{ end }}
    </body>
</html>

<style>
  body {
    font-family: 'Arial', sans-serif;
    background-color: #f4f4f4;
    color: #333;
    padding: 20px;
}

.page-title {
    color: #007BFF;
    text-align: center;
}

.todo-form {
    background: white;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    margin-top: 20px;
}

.form-header {
    font-size: 18px;
    color: #555;
    margin-bottom: 10px;
}

.form-body {
    margin-bottom: 20px;
}

.todo-textarea {
    width: 100%;
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
    resize: none;
    margin-bottom: 10px;
}

.submit-button {
    background-color: #28a745;
    color: white;
    border: none;
    padding: 10px 20px;
    border-radius: 5px;
    cursor: pointer;
    transition: background-color 0.3s;
    float: right;
}

.submit-button:hover {
    background-color: #218838;
}

.separator {
    border-top: 1px solid #ccc;
    margin-top: 20px;
}

.todo-item {
    padding: 10px;
    background-color: #fff;
    border-radius: 5px;
    box-shadow: 0 2px 5px rgba(0,0,0,0.1);
    margin-top: 10px;
}

.edit-link, .delete-link {
    color: #007BFF;
    text-decoration: none;
}

.edit-link:hover, .delete-link:hover {
    text-decoration: underline;
}

</style>
client/edit.html 全文
<html>
    <body>
        <h1 class="page-title">
            {{ .title }}
        </h1>

        <form class="todo-form" action="/todo/update" method="POST">
            <input type="hidden" name="id" value="{{.todo.ID}}" />
            <div class="form-header">Todos更新</div>
            <div class="form-body">
                <textarea class="todo-textarea" name="content" placeholder="Todoを更新" rows="4">{{.todo.Content}}</textarea>
                <button class="submit-button" type="submit">更新</button>
            </div>
        </form>

        <hr class="separator" />
    </body>
</html>

<style>
  body {
    font-family: 'Arial', sans-serif;
    background-color: #f4f4f4;
    color: #333;
    padding: 20px;
}

.page-title {
    color: #007BFF;
    text-align: center;
}

.todo-form {
    background: white;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    margin-top: 20px;
}

.form-header {
    font-size: 18px;
    color: #555;
    margin-bottom: 10px;
}

.form-body {
    margin-bottom: 20px;
}

.todo-textarea {
    width: 100%;
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
    resize: none;
    margin-bottom: 10px;
}

.submit-button {
    background-color: #28a745;
    color: white;
    border: none;
    padding: 10px 20px;
    border-radius: 5px;
    cursor: pointer;
    transition: background-color 0.3s;
    display: block; /* Makes the button block level, which might be preferable in some designs */
    width: 100%; /* Adjust if you want a smaller button */
}

.submit-button:hover {
    background-color: #218838;
}

.separator {
    border-top: 1px solid #ccc;
    margin-top: 20px;
}

</style>

解説

  • ginのHTMLの記述方はこちらが参考になりました。
    • 今回は、{{ range .todos }}...{{ end }}とすることで、やることリストを全て表示しております。
  • 基本的にGETメソッドはaタグ, POSTメソッドformタグでハンドリングしています。

動作確認

次回

今回は、gin + gormで基本的な書き方やCRUD処理を学びました。ginを用いてhtmlにレンダリングし、webアプリとして動かすことができました。
しかし、今回はmain.goで全て記載した為、アーキテクチャの観点では下記の問題点があります。

  • モデルの定義がORMに依存しすぎでいる
    • データの形式を定義する部分が、データベース操作ライブラリ(ORM)に依存しているため、他のデータベース操作方法に移行しにくい状態。
  • コードの再利用性が低い
    • コードが特定の状況に特化して書かれているため、他のプロジェクトで再利用しにくくなっています。
  • 依存性注入がない
    • 現状のmain.goのプログラムは、各部分が互いに強く依存しているため、変更やテストが困難になっています。

こういった観点を次回はクリーンアーキテクチャに置き換える形で直していきたいと思います。

参考

GORM 公式
GIN 公式
Gin(Golang)におけるHTMLテンプレート記述方法

Discussion