Closed72

Go言語によるTodoアプリの作成ログ

kip2kip2

Go言語でTodoアプリを作成するので、ログを残す試み

以下の書籍を参考とする予定
https://gihyo.jp/book/2023/978-4-297-13419-8

経緯としては
・人に教える必要が出たため、Todoアプリを題材にしようと思った
・ついでに自分もGoに入門したい
・mattnさんの以下の記事に触発されて、ベースとなるTodoアプリを作りたかった
https://levtech.jp/media/article/column/detail_473/
といったところ

なお、人に教えるときに共有するログになるため、記述がかなり冗長になると思われる

kip2kip2

まずDB周りのコードを学んで書いていこうと思っていたが、Rustで慣れ親しんだsqlxがgoに見つかったので、こっちを使うように方針変更
見切り発車なので、今後もこうやって方針をどしどし変えていくと思われる
https://github.com/jmoiron/sqlx

kip2kip2

Goのプラグインをインストール

DB接続のためのプラグインを、まずはインストール

# sqlx
go get github.com/jmoiron/sqlx
# driver
go get github.com/go-sql-driver/mysql
# dotenv
go get github.com/joho/godotenv

.envからの読み込み用に以下も追加
https://github.com/joho/godotenv

kip2kip2

.envからの値の読み込み

.envから読み込みをする処理を書いていく

まず、.envにテスト用の環境変数を記載する

TEST_VALUE="test value of dotenv"

次に、go側でこれを読み込んで表示するコードを書く

package main

import (
	"fmt"
	"log"
	"os"

	"github.com/joho/godotenv"
)

func main() {
	err := godotenv.Load()
	if err != nil {
		log.Fatalf("Error loading .env file: %v", err)
	}

	env := os.Getenv("TEST_VALUE")

	fmt.Println(env)
}

結果

$ go run main.go
test value of dotenv

取得できたので成功!
あとは本番用の環境変数に変更すればOK

(余談:.envのコードブロックはiniと書くとハイライトしてくれるんだね。最初envと書いてたけど、ハイライトしなかったので...)

kip2kip2

リファクタリング

リファクタリングして関数に切り出し

package main

import (
	"fmt"
	"log"
	"os"

	"github.com/joho/godotenv"
)

func main() {
	env := loadEnv("TEST_VALUE")
	fmt.Println(env)
}

func loadEnv(key string) string {
	err := godotenv.Load()
	if err != nil {
		log.Fatalf("Error loading .env file: %v", err)
	}

	env := os.Getenv(key)

	return env
}

ちゃんと結果も確かめながら、細かいステップを踏みながら進みましょうね〜、ということで動作確認

$ go run main.go
test value of dotenv

OK!

kip2kip2

MySQLのインストール方法

MySQLを使用するので、インストール方について書く
なお、MacとHomebrewを使用する前提

インストール

brew install mysql

サービスの起動

brew services start mysql

起動確認は以下のコマンドで行える

brew services list

初期設定

mysql -u root

アクセスしたら以下のコマンドでパスワードを設定する

ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '新しいパスワード';

ログイン

これでセットアップができたので、今後は、以下のコマンドでmysqlへのログインが行える

mysql -u root -p
# パスワードの入力が求められるので、パスワードを入力する

# ログイン成功すると、ターミナル上で以下の表示になる
mysql> 
kip2kip2

DB接続テスト用のテーブルを用意する

前提として、mysqlにログインしておくこと
ターミナルが以下の状態であればOK

mysql>

データベースの作成

まずはテスト用のデータベースを作成する

mysql> CREATE DATABASE your_database_name;

データベースの使用

作成したデータベースを使用状態にする

mysql> USE your_database_name;

テーブルの作成

接続テスト用のテーブルを作成する

mysql> CREATE TABLE users(
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL
);

テーブルが作成できたか確認する

mysql> SHOW TABLES;

なお、テーブル構造の確認には次のコマンドを使用する

mysql> DESCRIBE users;

初期データの投入

テスト用のデータをDBに登録する

mysql> INSERT INTO users (name) VALUES ('Alice');
mysql> INSERT INTO users (name) VALUES ('Bob');

データが登録されているかを確認する

mysql> SELECT * FROM users;
kip2kip2

テーブル接続のGoのコードを書く

作成したテーブルのデータと対応する構造体を定義する

type User struct {
	ID int `db:"id"`
	Name string `db:"name"`
}

この構造体を使って、DBのデータを受け取って表示するコードを書く

package main

import (
	"fmt"
	"log"
	"os"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
	"github.com/joho/godotenv"
)

type User struct {
	ID   int    `db:"id"`
	Name string `db:"name"`
}

func main() {

	// .envから環境変数の値を読み込み
	dsn := loadEnv("DATABASE")

	// dbとのコネクションを作成
	db, err := sqlx.Connect("mysql", dsn)
	if err != nil {
		log.Fatalln(err)
	}

	// dbコネクションを閉じるためのデコンストラクタ
	defer db.Close()

	// クエリを実行して結果を取得
	var users []User
	err = db.Select(&users, "SELECT id, name FROM users WHERE id=?", 1)
	if err != nil {
		log.Fatalln(err)
	}

	// クエリした結果を表示して確認
	for _, user := range users {
		fmt.Printf("ID: %d, Name: %s\n", user.ID, user.Name)
	}
}

func loadEnv(key string) string {
	err := godotenv.Load()
	if err != nil {
		log.Fatalf("Error loading .env file: %v", err)
	}

	env := os.Getenv(key)

	return env
}

実行して確認

$ go run main.go
ID: 1, Name: Alice
kip2kip2

リファクタリング

ここまでのコードのなかで、リファクタリングしたいところを挙げる

  • DBコネクション取得は外部関数に切り出したい
  • クエリの実行を外部関数に切り出したい

上は今やってもいいけど、下のは一旦保留かな~
あくまでテスト用のDB接続なので、切り出すには早い気がする

kip2kip2

DBコネクション取得を外部関数化

DBコネクション取得の外部関数への切り出し

package main

import (
	"fmt"
	"log"
	"os"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
	"github.com/joho/godotenv"
)

type User struct {
	ID   int    `db:"id"`
	Name string `db:"name"`
}

func main() {

	// dbとのコネクションを作成
	db := createDBConnection()

	// dbコネクションを閉じるためのデコンストラクタ
	defer db.Close()

	// クエリを実行して結果を取得
	var users []User
	err := db.Select(&users, "SELECT id, name FROM users WHERE id=?", 1)
	if err != nil {
		log.Fatalln(err)
	}

	// クエリした結果を表示して確認
	for _, user := range users {
		fmt.Printf("ID: %d, Name: %s\n", user.ID, user.Name)
	}
}

func createDBConnection() *sqlx.DB {
	dsn := loadEnv("DATABASE")

	db, err := sqlx.Connect("mysql", dsn)
	if err != nil {
		log.Fatalln(err)
	}

	return db
}

func loadEnv(key string) string {
	err := godotenv.Load()
	if err != nil {
		log.Fatalf("Error loading .env file: %v", err)
	}

	env := os.Getenv(key)

	return env
}

動作確認

$ go run main.go
ID: 1, Name: Alice
kip2kip2

今後、関数への切り出しが増えていくと何をする関数かぱっと見わからなくなるので、後のことを考えてドキュメントを追加

godocに使える形で記載するのを目標にしたい
以下は参考記事
https://qiita.com/shibukawa/items/8c70fdd1972fad76a5ce
https://zenn.dev/harachan/articles/db3149c1a19c32

// DBコネクションを作成する関数
func createDBConnection() *sqlx.DB {
	dsn := loadEnv("DATABASE")

	db, err := sqlx.Connect("mysql", dsn)
	if err != nil {
		log.Fatalln(err)
	}

	return db
}

// .envファイルから特定のキーに紐づく値を取得する関数
func loadEnv(key string) string {
	err := godotenv.Load()
	if err != nil {
		log.Fatalf("Error loading .env file: %v", err)
	}

	env := os.Getenv(key)

	return env
}
kip2kip2

コメントの形式間違えたので修正

/*
DBコネクションを作成する関数
*/
func createDBConnection() *sqlx.DB {
	dsn := loadEnv("DATABASE")

	db, err := sqlx.Connect("mysql", dsn)
	if err != nil {
		log.Fatalln(err)
	}

	return db
}

/*
.envファイルから特定のキーに紐づく値を取得する関数
*/
func loadEnv(key string) string {
	err := godotenv.Load()
	if err != nil {
		log.Fatalf("Error loading .env file: %v", err)
	}

	env := os.Getenv(key)

	return env
}
kip2kip2

ちょっとリファクタリング
環境変数指定のハードコーディングをやめて、引数で受け取れるように変更

/*
DBコネクションを作成する関数
*/
func createDBConnection(envKey string) *sqlx.DB {
	dsn := loadEnv(envKey)

	db, err := sqlx.Connect("mysql", dsn)
	if err != nil {
		log.Fatalln(err)
	}

	return db
}

使用側のコードは以下となる

// dbとのコネクションを作成
envKey := "DATABASE"
db := createDBConnection(envKey)
kip2kip2

ところで変数の名前、envKeyで意味あってるのだろうか...

「環境変数のキー」なんて言い回ししないよな〜となんとなく思った
環境変数は環境変数だし

というわけで変数名を修正

/*
DBコネクションを作成する関数
*/
func createDBConnection(envVar string) *sqlx.DB {
	dsn := loadEnv(envVar)

	db, err := sqlx.Connect("mysql", dsn)
	if err != nil {
		log.Fatalln(err)
	}

	return db
}

省略するが、呼び出し側も同様に変更している

kip2kip2

テストコードの作成

関数切り出しも行ったので、テストコードを書いて、今後、コードの変更をする場合の動作を担保したいと思う

とりあえずmain_test.goを作成する
例としてはtouchを使用して作成しているが、GUIを介して作成してもOK
注意としては、main.goと同じディレクトリに配置すること

touch main_test.go
package main

import (
	"os"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestLoadEnv(t *testing.T) {
	os.Setenv("DATABASE", "test-dsn")
	dsn := loadEnv("DATABASE")
	assert.Equal(t, "test-dsn", dsn, "環境変数の値が正しく読み込まれていません")
}

func TestCreateDBConnection(t *testing.T) {
	db := createDBConnection("DATABASE")
	defer db.Close()

	assert.NotNil(t, db, "DBコネクションが作成されていません")
}

これで、テスト対象の関数に変更があった場合に、ちゃんと動作するかを確かめることができる
とはいえ、テストケースを網羅したわけではないので、限定的にはなるけども

kip2kip2

テスト用DBに接続テストしていたコードを、テストコードとして切り出し

package main

import (
	"os"
	"testing"

	"github.com/stretchr/testify/assert"
)

// TestDB用の型定義なので、main関数から型定義を移動
type User struct {
	ID   int    `db:"id"`
	Name string `db:"name"`
}

/*
テスト用DBへの接続と値の取得テスト
*/
func TestDBQuery(t *testing.T) {
	envVar := "DATABASE"
	db := createDBConnection(envVar)
	defer db.Close()

	var users []User
	err := db.Select(&users, "SELECT id, name FROM users WHERE id=?", 1)

	// クエリ実行時のエラーをテスト
	assert.NoError(t, err, "クエリ実行時にエラーが発生しました")

	// 期待するUserデータ
	var expectedUser = User{
		ID:   1,
		Name: "Alice",
	}

	// テスト用ユーザーデータの取得をアサート
	assert.Equal(t, 1, len(users), "ユーザーが取得できませんでした")
	assert.Equal(t, expectedUser.ID, users[0].ID, "ユーザーIDが一致しません")
	assert.Equal(t, expectedUser.Name, users[0].Name, "ユーザー名が一致しません")
}

func TestLoadEnv(t *testing.T) {
	os.Setenv("DATABASE", "test-dsn")
	dsn := loadEnv("DATABASE")
	assert.Equal(t, "test-dsn", dsn, "環境変数の値が正しく読み込まれていません")
}

func TestCreateDBConnection(t *testing.T) {
	db := createDBConnection("DATABASE")
	defer db.Close()

	assert.NotNil(t, db, "DBコネクションが作成されていません")
}

だいぶん脇にそれたけど、これでDBとの接続テストがいつでも行えるようになった

kip2kip2

INSERTの場合

INSERTの場合の書き方

func main() {
	envVar := "DATABASE"
	db := createDBConnection(envVar)
	defer db.Close()

	name := "Charlie"

	result, err := db.Exec("INSERT INTO users (name) VALUES (?)", name)
	if err != nil {
		log.Fatalln(err)
	}

	lastInsertID, err := result.LastInsertId()
	if err != nil {
		log.Fatalln(err)
	}

	fmt.Printf("Inserted user with ID: %d\n", lastInsertID)
}
kip2kip2

エラーハンドリングについて

毎回以下のコードを書くのが面倒なのでマクロ化したい

if err != nil {
    log.Fatalln(err)
}

しかし、Go言語にはマクロはないっぽいので、関数で記載する(単に見つけられてないだけかも)

/*
エラーハンドリング用のコード(マクロ代わり)
*/
func checkError(err error) {
	if err != nil {
		log.Fatalln(err)
	}
}

使い方は以下の用にすればいい

checkError(err)

一例として、先程のコードを直したものを示す


func main() {
	envVar := "DATABASE"
	db := createDBConnection(envVar)
	defer db.Close()

	name := "Charlie"

	result, err := db.Exec("INSERT INTO users (name) VALUES (?)", name)
	// エラーハンドリング
	checkError(err)

	lastInsertID, err := result.LastInsertId()
	// エラーハンドリング
	checkError(err)

	fmt.Printf("Inserted user with ID: %d\n", lastInsertID)
}

/*
エラーハンドリング用のコード(マクロ代わり)
*/
func checkError(err error) {
	if err != nil {
		log.Fatalln(err)
	}
}
kip2kip2

INSET関数の再利用のために

後々再利用する可能性もあるため、一旦外部関数として切り出す
とはいえ、接続テスト用のDBに対するコードのため今後再利用の可能性はないと思うが、まあ切り出し方の手順として、ね?

func main() {
	name := "David"
	insert(name)
}

func insert(name string) {
	envVar := "DATABASE"
	db := createDBConnection(envVar)
	defer db.Close()

	result, err := db.Exec("INSERT INTO users (name) VALUES (?)", name)
	checkError(err)

	lastInsertID, err := result.LastInsertId()
	checkError(err)

	fmt.Printf("Inserted user with ID: %d\n", lastInsertID)
}
kip2kip2

ぼちぼち関数が散らかってきたので、整理しよう

kip2kip2

フォルダ整理

main.goにすべての関数を書いているので、各関数を機能ごとにまとめる形に修正する
フォルダを切って、別パッケージとする
具体的には以下の構造とする

.
├── db
│   └── db.go
├── env
│   └── env.go
├── error
│   └── error.go
├── go.mod
├── go.sum
├── main.go
└── main_test.go

4 directories, 7 files

個別のコードは以下に示す

db

package db

import (
	"fmt"
	"log"
	"todoApp/env"
	errorpkg "todoApp/error"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
)

/*
データをDBにINSERTする
*/
func Insert(name string) {
	envVar := "DATABASE"
	db := CreateDBConnection(envVar)
	defer db.Close()

	result, err := db.Exec("INSERT INTO users (name) VALUES (?)", name)
	errorpkg.CheckError(err)

	lastInsertID, err := result.LastInsertId()
	errorpkg.CheckError(err)

	fmt.Printf("Inserted user with ID: %d\n", lastInsertID)
}

/*
MySQLのDBコネクションを作成する関数
*/
func CreateDBConnection(envVar string) *sqlx.DB {
	dsn := env.LoadEnv(envVar)

	db, err := sqlx.Connect("mysql", dsn)
	if err != nil {
		log.Fatalln(err)
	}

	return db
}

env

package env

import (
	"log"
	"os"

	"github.com/joho/godotenv"
)

/*
.envファイルから特定のキーに紐づく値を取得する関数
*/
func LoadEnv(key string) string {
	// .envに定義した環境変数をロード
	err := godotenv.Load()
	if err != nil {
		log.Fatalf("Error loading .env file: %v", err)
	}

	// 環境変数から値を取得
	env := os.Getenv(key)

	return env
}

error

予約後のため、package名にerrorは使えないとのこと
errorpkgとしている

package errorpkg

import "log"

/*
エラーハンドリング用
*/
func CheckError(err error) {
	if err != nil {
		log.Fatalln(err)
	}
}

main

main.goがスッキリした

package main

import "todoApp/db"

func main() {
	name := "David"
	db.Insert(name)
}
kip2kip2

sqlxの動作確認はこれでいいと思うので、実際にTodoアプリで使うDB設計をしようと思う

kip2kip2

DB設計

参考書籍から丸パクリだが、以下のDBとしたい

SQLに起こしたもの

CREATE TABLE todos (
    ID INT AUTO_INCREMENT PRIMARY KEY,
    Content VARCHAR(255) NOT NULL,
    Done TINYINT(1) NOT NULL DEFAULT 0,
    Until DATETIME,
    CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    DeletedAt DATETIME DEFAULT NULL
);

MySQLにログインして、このテーブルを作成しておく

mysql> CREATE TABLE todos (
    ->     ID INT AUTO_INCREMENT PRIMARY KEY,
    ->     Content VARCHAR(255) NOT NULL,
    ->     Done TINYINT(1) NOT NULL DEFAULT 0,
    ->     Until DATETIME,
    ->     CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    ->     UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    ->     DeletedAt DATETIME DEFAULT NULL
    -> );

実際に作成されているかを確認する

mysql> show tables;
+--------------------+
| Tables_in_todo_app |
+--------------------+
| todos              |
| users              |
+--------------------+
2 rows in set (0.00 sec)

mysql> describe todos;
+-----------+--------------+------+-----+-------------------+-----------------------------------------------+
| Field     | Type         | Null | Key | Default           | Extra                                         |
+-----------+--------------+------+-----+-------------------+-----------------------------------------------+
| ID        | int          | NO   | PRI | NULL              | auto_increment                                |
| Content   | varchar(255) | NO   |     | NULL              |                                               |
| Done      | tinyint(1)   | NO   |     | 0                 |                                               |
| Until     | datetime     | YES  |     | NULL              |                                               |
| CreatedAt | datetime     | NO   |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED                             |
| UpdatedAt | datetime     | NO   |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP |
| DeletedAt | datetime     | YES  |     | NULL              |                                               |
+-----------+--------------+------+-----+-------------------+-----------------------------------------------+
7 rows in set (0.01 sec)
kip2kip2

API設計

さて、色々準備できたので、バックエンドの動作を作り込んでいく
今回、バックエンドはAPIとしてフロントとやり取りを行い、DBから読み出したり、DBに登録したりする機能で行こうと思う

まずはAPIの設計から

ほしい要件を箇条書きする

  • APIとはJSON形式でやり取りする
  • Todoリストの一覧表示のための、すべてのTodoを取得するエンドポイント
  • ContentとUntilの値をJSONでPOSTし、データベースに登録するエンドポイント
  • IDと紐づいたTodoの削除を行うエンドポイント

さて、各要件を細かく詰めていこう

Todo一覧表示のためのエンドポイント

まず一覧表示のためのエンドポイント

  • getで要求したら全てのデータがJSONで返ってくる
  • Todos型の値として返ってくる
  • ID, Content, Until, Doneあたりを受け取るのみでいいと思うが、すべてのカラムを受け取る形で一旦考えること

Todoをデータベースに登録するエンドポイント

次にDB登録用のエンドポイント

  • ContentとUntilが記載されているJSONを受け取る
  • 登録できたか否かをレスポンスする機能は、処理簡易化のため、今回は作らない

Todoを削除するエンドポイント

最後にTodoを削除するエンドポイント

  • TodoのIDの記載があるJSONを受け取る
  • DBにあるIDと紐づくTodoのDeletedAtを更新する

気づき

DeletedAtの扱いが微妙な感じがする...
あとあと、ここは変更するかも

kip2kip2

Todo取得処理

ダミーデータの登録

取得確認のためのダミーデータを登録する
他の項目は自動で埋まるか、NULL許容しているデータのため、Contentのみをインサートすればデータは作れる

mysql> INSERT INTO todos (Content) VALUES ("test todo");
Query OK, 1 row affected (0.01 sec)

mysql> select * from todos;
+----+-----------+------+-------+---------------------+---------------------+-----------+
| ID | Content   | Done | Until | CreatedAt           | UpdatedAt           | DeletedAt |
+----+-----------+------+-------+---------------------+---------------------+-----------+
|  1 | test todo |    0 | NULL  | 2024-10-02 20:21:16 | 2024-10-02 20:21:16 | NULL      |
+----+-----------+------+-------+---------------------+---------------------+-----------+
1 row in set (0.01 sec)

ダミーデータの取得処理

一旦、main.goに定義する
db.goに書くのが適切だが、golangでの外部パッケージへの定義がよく分かっていないため、リファクタリングの中で行うことにする
まずは動くコードを書くのが先決

package main

import (
	"fmt"
	"time"
	"todoApp/db"
	errorpkg "todoApp/error"
)

// DBに紐づくデータの定義
type Todo struct {
	ID        int        `db:"ID"`
	Content   string     `db:"Content"`
	Done      bool       `db:"Done"`
	Until     *time.Time `db:"Until"`
	CreatedAt time.Time  `db:"CreatedAt"`
	UpdatedAt time.Time  `db:"UpdatedAt"`
	DeletedAt *time.Time `db:"DeletedAt"`
}

func main() {

	envVar := "DATABASE"
	db := db.CreateDBConnection(envVar)
	defer db.Close()

	var todo Todo
	err := db.Get(&todo, "SELECT * FROM todos WHERE id=?", 1)
	errorpkg.CheckError(err)

	fmt.Printf("Todo: %+v\n", todo)
}

動作確認

$ go run main.go
Todo: {ID:1 Content:test todo Done:false Until:<nil> CreatedAt:2024-10-02 20:21:16 +0900 JST UpdatedAt:2024-10-02 20:21:16 +0900 JST DeletedAt:<nil>}

外部関数に切り出す

IDによる取得処理を外部関数に切り出す

package main

import (
	"fmt"
	"time"
	"todoApp/db"
	errorpkg "todoApp/error"
)

type Todo struct {
	ID        int        `db:"ID"`
	Content   string     `db:"Content"`
	Done      bool       `db:"Done"`
	Until     *time.Time `db:"Until"`
	CreatedAt time.Time  `db:"CreatedAt"`
	UpdatedAt time.Time  `db:"UpdatedAt"`
	DeletedAt *time.Time `db:"DeletedAt"`
}

func main() {
	todo := selectById(1)
	fmt.Printf("Todo: %+v\n", todo)
}

/*
指定したIDのTodoをDBから取得する
*/
func selectById(id int) Todo {
	envVar := "DATABASE"
	db := db.CreateDBConnection(envVar)
	defer db.Close()

	var todo Todo
	err := db.Get(&todo, "SELECT * FROM todos WHERE id=?", id)
	errorpkg.CheckError(err)

	return todo
}

db.goへコードを移動

あとはこれをdb.goに移動すれば良い
移動したのが以下のコードとなる

あと、ついでに環境変数はグローバル変数に変更した
こうすることで変更するときもここだけ変えれば良くなるので

db.go

package db

import (
	"fmt"
	"log"
	"time"
	"todoApp/env"
	errorpkg "todoApp/error"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
)

// グローバル変数として定義した
const envVar = "DATABASE"

type Todo struct {
	ID        int        `db:"ID"`
	Content   string     `db:"Content"`
	Done      bool       `db:"Done"`
	Until     *time.Time `db:"Until"`
	CreatedAt time.Time  `db:"CreatedAt"`
	UpdatedAt time.Time  `db:"UpdatedAt"`
	DeletedAt *time.Time `db:"DeletedAt"`
}

/*
指定したIDのTodoをDBから取得する
*/
func SelectById(id int) Todo {
	db := CreateDBConnection(envVar)
	defer db.Close()

	var todo Todo
	err := db.Get(&todo, "SELECT * FROM todos WHERE id=?", id)
	errorpkg.CheckError(err)

	return todo
}

/*
データをDBにINSERTする(test用)
*/
func Insert(name string) {
	db := CreateDBConnection(envVar)
	defer db.Close()

	result, err := db.Exec("INSERT INTO users (name) VALUES (?)", name)
	errorpkg.CheckError(err)

	lastInsertID, err := result.LastInsertId()
	errorpkg.CheckError(err)

	fmt.Printf("Inserted user with ID: %d\n", lastInsertID)
}

/*
MySQLのDBコネクションを作成する関数
*/
func CreateDBConnection(envVar string) *sqlx.DB {
	dsn := env.LoadEnv(envVar)

	db, err := sqlx.Connect("mysql", dsn)
	if err != nil {
		log.Fatalln(err)
	}

	return db
}

接続テスト用の関数はいらないため、そのうち削除予定
今のところはそのままおいている

kip2kip2

複数データの取得

目的は全てのデータの取得のため、リストで受け取る必要がある

ダミーデータの追加

複数件受け取る処理をテストするためのダミーデータを追加する
1件でもよいが、せっかくなので2件追加した


mysql> INSERT INTO todos (Content) VALUES ("test todo2");
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO todos (Content) VALUES ("test todo3");
Query OK, 1 row affected (0.01 sec)

mysql> select * from todos;
+----+------------+------+-------+---------------------+---------------------+-----------+
| ID | Content    | Done | Until | CreatedAt           | UpdatedAt           | DeletedAt |
+----+------------+------+-------+---------------------+---------------------+-----------+
|  1 | test todo  |    0 | NULL  | 2024-10-02 20:21:16 | 2024-10-02 20:21:16 | NULL      |
|  2 | test todo2 |    0 | NULL  | 2024-10-02 20:54:01 | 2024-10-02 20:54:01 | NULL      |
|  3 | test todo3 |    0 | NULL  | 2024-10-02 20:54:04 | 2024-10-02 20:54:04 | NULL      |
+----+------------+------+-------+---------------------+---------------------+-----------+
3 rows in set (0.00 sec)

すべてのデータを受け取る処理を作成する

コツが掴めてきたので、今度はdb.goに直接定義していく。

db.go

/*
すべてのTodoをDBから取得する
*/
func SelectAll() []Todo {
	db := CreateDBConnection(envVar)
	defer db.Close()

	var todo []Todo
	err := db.Select(&todo, "SELECT * FROM todos")
	errorpkg.CheckError(err)

	return todo
}

確認ようにmain.goに以下のコードを記載する

main.go

package main

import (
	"fmt"
	"todoApp/db"
)

func main() {
	todos := db.SelectAll()

	for _, t := range todos {
		fmt.Printf("Todo: %+v\n", t)
	}
}

動作確認をする

$ go run main.go
Todo: {ID:1 Content:test todo Done:false Until:<nil> CreatedAt:2024-10-02 20:21:16 +0900 JST UpdatedAt:2024-10-02 20:21:16 +0900 JST DeletedAt:<nil>}
Todo: {ID:2 Content:test todo2 Done:false Until:<nil> CreatedAt:2024-10-02 20:54:01 +0900 JST UpdatedAt:2024-10-02 20:54:01 +0900 JST DeletedAt:<nil>}
Todo: {ID:3 Content:test todo3 Done:false Until:<nil> CreatedAt:2024-10-02 20:54:04 +0900 JST UpdatedAt:2024-10-02 20:54:04 +0900 JST DeletedAt:<nil>}
kip2kip2

Goのディレクトリ構成

Goのディレクトリ構成について学んでみる

公式のディレクトリ構成のドキュメント
https://zenn.dev/furon/articles/2fad1ba7a82171

作りながらディレクトリ分割する方法について
https://zenn.dev/foxtail88/articles/824c5e8e0c6d82

ディレクトリ分割の参考
https://qiita.com/Nori1983/items/7279a4f1f9c977336879

選択

公式のディレクトリ構成が良さそうなので、それを採用してみようかなと思う

  • cmdにエントリーポイント(プログラムが実行を開始する場所、プログラムの起動時に最初に実行される関数やコード)を配置
  • internalに他のもの(DB接続コードや、envの読み込み等のユーティリティ関数)を配置

といったようなざっくり理解で見切り発車する

kip2kip2

ディレクトリ構成の見直し

まず現在の構成がどうなっているか

$ tree
.
├── db
│   └── db.go
├── env
│   └── env.go
├── error
│   └── error.go
├── go.mod
├── go.sum
├── main.go
└── main_test.go

4 directories, 7 files

見直し

配置の見直しとしては次のようになると思う

internalに配置するもの

  • db.go
  • env.go
  • error.go

cmdに配置するもの

  • main.go(まだ未実装だが、APIのエントリーポイントになる想定のため)

できるところまで変更する

とりあえず、上記の個別のユーティリティ関数群はinternalに移せそうなので移しておく

main.goはまだ移動しない
エントリーポイントが増えた、あるいはAPIserverとして完成したら移動しようと思う

$ tree
.
├── go.mod
├── go.sum
├── internal
│   ├── db
│   │   └── db.go
│   ├── env
│   │   └── env.go
│   └── error
│       └── error.go
├── main.go
└── main_test.go

5 directories, 7 files
kip2kip2

データをJSONにシリアライズ

APIとしてJSONデータで受け渡しを行うため、JSONへのシリアライズ処理を書く

以下のコマンドでファイルを作成する

mkdir internal/json
touch internal/json/json.go

シリアライズ処理を書く
テストのため、JSONファイルとして保存するコードとして書いている

package json

import (
	"encoding/json"
	"fmt"
	"os"
	"todoApp/internal/db"
)

func SerializeTodos(todos []db.Todo) {
	filename := "test.json"
	err := SaveToJson(filename, todos)

	if err != nil {
		fmt.Println("Error saving to JSON:", err)
	} else {
		fmt.Println("Successfully saved to users.json")
	}
}

func SaveToJson(filename string, data interface{}) error {
	file, err := os.Create(filename)
	if err != nil {
		return err
	}
	defer file.Close()

	encoder := json.NewEncoder(file)
	encoder.SetIndent("", " ")
	err = encoder.Encode(data)
	if err != nil {
		return err
	}

	return nil
}

動作確認

main.goに以下のコードを書いて、動作を確認する

package main

import (
	"fmt"
	"todoApp/internal/db"
	"todoApp/internal/json"
)

func main() {
	todos := db.SelectAll()

	for _, t := range todos {
		fmt.Printf("Todo: %+v\n", t)
	}

	fmt.Println("Serialize Todos data to json data")

	json.SerializeTodos(todos)
}

実行する

$ % go run main.go
Todo: {ID:1 Content:test todo Done:false Until:<nil> CreatedAt:2024-10-02 20:21:16 +0900 JST UpdatedAt:2024-10-02 20:21:16 +0900 JST DeletedAt:<nil>}
Todo: {ID:2 Content:test todo2 Done:false Until:<nil> CreatedAt:2024-10-02 20:54:01 +0900 JST UpdatedAt:2024-10-02 20:54:01 +0900 JST DeletedAt:<nil>}
Todo: {ID:3 Content:test todo3 Done:false Until:<nil> CreatedAt:2024-10-02 20:54:04 +0900 JST UpdatedAt:2024-10-02 20:54:04 +0900 JST DeletedAt:<nil>}
Serialize Todos data to json data
Successfully saved to users.json

保存されたファイルを確認

test.json

[
 {
  "ID": 1,
  "Content": "test todo",
  "Done": false,
  "Until": null,
  "CreatedAt": "2024-10-02T20:21:16+09:00",
  "UpdatedAt": "2024-10-02T20:21:16+09:00",
  "DeletedAt": null
 },
 {
  "ID": 2,
  "Content": "test todo2",
  "Done": false,
  "Until": null,
  "CreatedAt": "2024-10-02T20:54:01+09:00",
  "UpdatedAt": "2024-10-02T20:54:01+09:00",
  "DeletedAt": null
 },
 {
  "ID": 3,
  "Content": "test todo3",
  "Done": false,
  "Until": null,
  "CreatedAt": "2024-10-02T20:54:04+09:00",
  "UpdatedAt": "2024-10-02T20:54:04+09:00",
  "DeletedAt": null
 }
]
kip2kip2

書きはしたけども

JSONファイル保存コードを書いたけど、見直すと間違っていることに気づいた
ファイルに保存するのではなく、HTTPレスポンスとしてのJSONにしないといけない

というわけで修正していく

Todoは以下になる

  • サーバーを立てるコードを書く
  • JSONでレスポンスするコードを書く

サーバーコード

サーバーを立てて、JSONでレスポンするコードを作成

main.go

package main

import (
	"encoding/json"
	"net/http"
	"todoApp/internal/db"
)

func main() {
	http.HandleFunc("/todos", todosHandler)

	http.ListenAndServe(":8080", nil)
}

func todosHandler(w http.ResponseWriter, r *http.Request) {
	todos := db.SelectAll()

	w.Header().Set("Content-Type", "application/json")

	err := json.NewEncoder(w).Encode(todos)
	if err != nil {
		http.Error(w, "Failed to encode users", http.StatusInternalServerError)
		return
	}
}

動作確認

まずサーバーを起動する

$ go run main.go
# 待受状態になる

この状態で、http://localhost:8080/todosにブラウザでアクセスする

動作が確認できた


余談

Goでサーバーを立ててJSONでレスポンスするコードのシンプルさに驚いた
こんな簡単に実現できるようになってるんだね〜

kip2kip2

DB構成の見直し

DeletedAtの扱いを参考書籍と同じようにしたが、今回の目的からするとこれは不要ではないかと考える
Todoのデリートをするときは、単純にDBから消しただけで良いと考える

というわけでDBの構成を変える

  • DeletedAtを削除する

カラムの削除

mysqlクライアントからカラムの削除を実行

mysql> ALTER TABLE todos DROP COLUMN DeletedAt;
Query OK, 0 rows affected (0.02 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> select * from todos;
+----+------------+------+-------+---------------------+---------------------+
| ID | Content    | Done | Until | CreatedAt           | UpdatedAt           |
+----+------------+------+-------+---------------------+---------------------+
|  1 | test todo  |    0 | NULL  | 2024-10-02 20:21:16 | 2024-10-02 20:21:16 |
|  2 | test todo2 |    0 | NULL  | 2024-10-02 20:54:01 | 2024-10-02 20:54:01 |
|  3 | test todo3 |    0 | NULL  | 2024-10-02 20:54:04 | 2024-10-02 20:54:04 |
+----+------------+------+-------+---------------------+---------------------+
3 rows in set (0.00 sec)

構造体も修正

構造体にもカラムの定義があるため、削除する

type Todo struct {
	ID        int        `db:"ID"`
	Content   string     `db:"Content"`
	Done      bool       `db:"Done"`
	Until     *time.Time `db:"Until"`
	CreatedAt time.Time  `db:"CreatedAt"`
	UpdatedAt time.Time  `db:"UpdatedAt"`
	// ここを削除する
	// DeletedAt *time.Time `db:"DeletedAt"`
}
kip2kip2

DBへの登録処理を書く(前編)

DBからの全データ取得はかけたので、今度はリクエストされたデータを受け取ってDBへ登録する処理を書く

とりあえず動く物を作成する

いつもやっている以下の方針に従い、とりあえず動く物を作成する

  • まず動く物を書く
  • 動くものに変更を加えて、動作を確かめながら作成する
  • 動作がおかしいようであれば動くように直す

動作確認用のため、今回使用するデータ(構造体)はダミー用のものとなる
ダミーデータで一旦動作を確認し、その後本番用のデータ(構造体)に差し替えていく方法を取りたい

エンドポイント

  • /registerをエンドポイントとする
  • リクエストはJSONを受け付ける
  • リクエストJSONの中身は次のような感じ
{
    "Content": "content string"
}

テストコード

TDD式に、先にテストから書く

func TestRegisterHandler(t *testing.T) {
	// リクエスト用のJSONデータの作成
	reqBody := User{
		ID:   1,
		Name: "John",
	}
	jsonData, err := json.Marshal(reqBody)
	if err != nil {
		t.Fatalf("Failed to marshal request: %v", err)
	}

	// JSONリクエストの作成
	req, err := http.NewRequest("POST", "/register", bytes.NewBuffer(jsonData))
	if err != nil {
		t.Fatalf("Failed to create request: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")

	// レスポンス記録のためのレコーダーを用意
	rr := httptest.NewRecorder()

	// ハンドラーの呼び出し
	handler := http.HandlerFunc(registerHandler)
	handler.ServeHTTP(rr, req)

	// ステータスコードが200かの確認
	if status := rr.Code; status != http.StatusOK {
		t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK)
	}

	// レスポンスの内容を確認
	var resBody Response
	if err := json.NewDecoder(rr.Body).Decode(&resBody); err != nil {
		t.Fatalf("Failed to decode response: %v", err)
	}

	expectedMessage := "Hello, John"
	if resBody.Message != expectedMessage {
		t.Errorf("Handler returned unexpected body: got %v want %v", resBody.Message, expectedMessage)
	}
}

(参考資料)
json.Marshal
https://zenn.dev/satumahayato010/articles/ae2484d53c6a11

ハンドラー関数を書く

テストコードで書いたRegisterHandlerは存在しないため、その関数を実装していく


type User struct {
	ID   int    `db:"id"`
	Name string `db:"name"`
}

type Response struct {
	Message string `json:"message"`
}

func main() {
	http.HandleFunc("/todos", todosHandler)
	http.HandleFunc("/register", registerHandler)

	http.ListenAndServe(":8080", nil)
}

func registerHandler(w http.ResponseWriter, r *http.Request) {
	var req User
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	response := Response{
		Message: "Hello, " + req.Name,
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)

}
kip2kip2

DBへの登録処理を書く(中編)

さて、動くものは作成できたので、本番用のデータに差し替えて動作を確認していこう

クライアントから受け取るデータ

クライアントから受け取るデータを整理する

サーバー側で扱うデータは以下の形になっているが、DB側で自動付与するカラムもあるため、一対一対応はしない

CREATE TABLE todos (
    ID INT AUTO_INCREMENT PRIMARY KEY,
    Content VARCHAR(255) NOT NULL,
    Done TINYINT(1) NOT NULL DEFAULT 0,
    Until DATETIME,
    CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

さて、クライアントからは何を渡せばいいだろうか?

DB側で自動でデータを登録するカラムは、

  • ID
  • CreatedAt
  • UpdatedAt

の3つ

残った3つの中でクライアントから渡すべきものはなんだろうか

まずContentは必須。Todoの内容を示す文字列のため、クライアントから渡すべき情報だから

Doneはどうだろうか。こちらは最初から1(True)で埋まることはないので、0梅で正しいとなる。そうなると、この項目もクライアントから渡す必要はない

Untilはどうだろうか。「いつまでに終わらせる」といった情報は、一意に定まる値ではないため(デフォルト値は今回無いので)、クライアントから明示的に渡す必要のある値

以上の整理より、

  • Content
  • Until

以上の2つを渡す必要があるということになる

データ構造の整理

さて、以上の2つを渡すにあたって、構造体を定義しよう

必要なものは2つある

  • リクエスト時に渡すJSONに紐づく構造体
  • レスポンス時に返すJSONに紐づく構造体

急に出てきた、レスポンス時に返すJSONとはなんだろうか?
リクエストからデータを受け取ってデータを登録したあと、結果が成功か失敗かを情報をしてクライアントに返す必要があるので、そのためのJSON定義

というわけで2つ定義していく

main.go

// 登録用リクエストの構造体
type RegisterRequest struct {
	Content string    `db:"Content"`
	Until   time.Time `db:"Until"`
}

// 登録結果のレスポンス用の構造体
type Response struct {
	Result string `json:"result"`
}

レスポンス用の構造体は、他のエンドポイントでも共通で使われる可能性があるため、Responseという抽象的な名前にした
実装の中で変更がある場合に、名前を変えていく方向で行きたい(YAGNIの法則の考え方)

(参考)
日付フォーマットが2006-01-02となっている理由
https://blog.toshimaru.net/go-time-format/

kip2kip2

構造体を別のファイルに切り出す

構造体が増えてきたため、個別のファイルに切り出して管理する

internalディレクトにmodelsというディレクトリを作成し、構造体を保存するtodo.goというファイルを作成

$ tree
.
├── go.mod
├── go.sum
├── internal
│   ├── db
│   │   └── db.go
│   ├── env
│   │   └── env.go
│   ├── error
│   │   └── error.go
│   └── models
│       └── todo.go
├── main.go
├── main_test.go
└── test.json

6 directories, 9 files

構造体をこちらに移していく

internal/models/todo.go

package models

import "time"

type RegisterRequest struct {
	Content string    `db:"Content"`
	Until   time.Time `db:"Until"`
}

type Response struct {
	Result string `json:"result"`
}

type Todo struct {
	ID        int        `db:"ID"`
	Content   string     `db:"Content"`
	Done      bool       `db:"Done"`
	Until     *time.Time `db:"Until"`
	CreatedAt time.Time  `db:"CreatedAt"`
	UpdatedAt time.Time  `db:"UpdatedAt"`
}

// 動作test用の構造体
type User struct {
	ID   int    `db:"id"`
	Name string `db:"name"`
}

構造体呼び出しを変更する

構造体の呼び出しを、以上のファイルから呼び出す形に変更する必要がある

やることは以下の2点

  • importを書く。あるいはimport先を変更する
  • models.Structといった記載に変更する

一例として、db.goの関数を挙げているが、他の関数も同様に変更している

/*
指定したIDのTodoをDBから取得する
*/
// 返り値の型の呼び出しが変わった
func SelectById(id int) models.Todo {
	db := CreateDBConnection(envVar)
	defer db.Close()

	// 変数宣言も同様に型の呼び出しを変えた
	var todo models.Todo
	err := db.Get(&todo, "SELECT * FROM todos WHERE id=?", id)
	errorpkg.CheckError(err)

	return todo
}
kip2kip2

DBへの登録処理を書く(後編)

DB登録コードの変更で残った部分を作成

テストコード

まずテストコードを変更する

func TestRegisterHandler(t *testing.T) {
	// リクエスト用のJSONデータの作成
	untilTime := "2024-12-31"
	untilDate, err := time.Parse("2006-01-02", untilTime)

	reqBody := models.RegisterRequest{
		Content: "todo test content",
		Until:   untilDate,
	}

	jsonData, err := json.Marshal(reqBody)
	if err != nil {
		t.Fatalf("Failed to marshal request: %v", err)
	}

	// JSONリクエストの作成
	req, err := http.NewRequest("POST", "/register", bytes.NewBuffer(jsonData))
	if err != nil {
		t.Fatalf("Failed to create request: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")

	// レスポンス記録のためのレコーダーを用意
	rr := httptest.NewRecorder()

	// ハンドラーの呼び出し
	handler := http.HandlerFunc(registerHandler)
	handler.ServeHTTP(rr, req)

	// ステータスコードが200かの確認
	if status := rr.Code; status != http.StatusOK {
		t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK)
	}

	// レスポンスの内容を確認
	var resBody models.Response
	if err := json.NewDecoder(rr.Body).Decode(&resBody); err != nil {
		t.Fatalf("Failed to decode response: %v", err)
	}

	expectedMessage := "SUCCESS"
	if resBody.Result != expectedMessage {
		t.Errorf("Handler returned unexpected body: got %v want %v", resBody.Result, expectedMessage)
	}
}

ハンドラー関数(main.go)

次に、それにテストを通るようにハンドラー関数を変更する

func registerHandler(w http.ResponseWriter, r *http.Request) {
	var req models.RegisterRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	// 成功の場合のResponseデータを作成
	response := models.Response{
		Result: "SUCCESS",
	}

	// DBへの登録処理を行う
	_, err := db.Insert(req)
	// DB登録処理が失敗なら、エラーメッセージを格納したResponseデータに変更
	if err != nil {
		response = models.Response{
			Result: "Data register error.",
		}
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)

}

db.go

DBへのインサート処理を追加
なお、失敗したかどうかを判定する必要があるため、判定結果を返すようにしている

/*
データをDBにINSERTする
*/
func Insert(data models.RegisterRequest) (int64, error) {
	db := CreateDBConnection(envVar)
	defer db.Close()

	result, err := db.Exec("INSERT INTO todos (Content, Until) VALUES (?, ?)", data.Content, data.Until)
	errorpkg.CheckError(err)

	lastInsertID, err := result.LastInsertId()
	if err != nil {
		return 0, err
	}

	return lastInsertID, err
}
kip2kip2

コメントの追加

だんだん複雑になってきたので、コメントを追加(ほぼ自分用)

コメントを追加する効用として自分が感じているものは

  • あとで見返した時、関数の細かい動作を忘れていることがあるので(というよりオールウェイズ)、コメントがあったほうが変更するときにスムーズになる
  • VSCodeの拡張で関数にマウスホバーすると関数の情報が出るが、そのときに簡単な関数の概略が分かって良い
  • Document生成ツールなどを使うときに、自動でコメントが説明として表示されるのが便利。あとでコメント追加しようとするとコードすべて読まないといけないので、わかるうちにコメント書いておく考え。
package main

import (
	"encoding/json"
	"net/http"
	"todoApp/internal/db"
	"todoApp/internal/models"
)

func main() {
	// リスト(todo)の一覧を取得するハンドラのバインド
	http.HandleFunc("/todos", todosHandler)
	// リクエストしたデータを登録するハンドラのバインド
	http.HandleFunc("/register", registerHandler)

	http.ListenAndServe(":8080", nil)
}

/*
リクエストに含まれるデータをDBに登録するハンドラ
*/
func registerHandler(w http.ResponseWriter, r *http.Request) {
	var req models.RegisterRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	// 成功の場合のResponseデータを作成
	response := models.Response{
		Result: "SUCCESS",
	}

	// DBへの登録処理を行う
	_, err := db.Insert(req)
	// DB登録処理が失敗なら、エラーメッセージを格納したResponseデータに変更
	if err != nil {
		response = models.Response{
			Result: "Data register error.",
		}
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)

}

/*
DBからTodoの全リストを取得して、レスポンスするハンドラ
*/
func todosHandler(w http.ResponseWriter, r *http.Request) {
	todos := db.SelectAll()

	w.Header().Set("Content-Type", "application/json")

	err := json.NewEncoder(w).Encode(todos)
	if err != nil {
		http.Error(w, "Filed to encode users", http.StatusInternalServerError)
		return
	}
}

kip2kip2

登録データの削除

APIでDBデータのDelete処理を実装する。

仕様

  • JSONでDelete対象のデータのIDをリクエストする
  • エンドポイントは/delete
  • レスポンスはエラーか成功かを示すメッセージがJSONで返ってくる

型定義

internal/models/todo.goにリクエスト用の型定義を追加する。
レスポンス用の型定義はすでに定義したものを使い回すので、ここでは定義不要。

type DeleteRequest struct {
	ID int `db:"ID"`
}

テストコードを先に書く

テストコードから書いていく。
登録時に使ったものを使いまわして変更する感じで作る。

func TestDeleteHandler(t *testing.T) {
	reqBody := models.DeleteRequest{
		ID: 1,
	}

	jsonData, err := json.Marshal((reqBody))
	if err != nil {
		t.Fatalf("Failed to marshal request: %v", err)
	}

	req, err := http.NewRequest("POST", "/delete", bytes.NewBuffer(jsonData))
	if err != nil {
		t.Fatalf("Failed to delete request: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")

	rr := httptest.NewRecorder()

	handler := http.HandlerFunc(deleteHandler)
	handler.ServeHTTP(rr, req)

	if status := rr.Code; status != http.StatusOK {
		t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK)
	}

	var resBody models.Response
	if err := json.NewDecoder(rr.Body).Decode(&resBody); err != nil {
		t.Fatalf("Failed to decode response: %v", err)
	}

	expectedMessage := "SUCCESS"
	if resBody.Result != expectedMessage {
		t.Errorf("Handler returned unexpected body: got %v want %v", resBody.Result, expectedMessage)
	}
}

入出力が決まっているから、基本的に変えなくていいのは利点。
変わっているのは

  • 入力がDeleteRequest型のJSONデータ

のみだと思う。

ハンドラ関数を書く

ハンドラ関数を書く

main.go

/*
リクエストで指定したIDのデータを削除するハンドラ
*/
func deleteHandler(w http.ResponseWriter, r *http.Request) {
	var req models.DeleteRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	response := models.Response{
		Result: "SUCCESS",
	}

	// DBの削除処理を行う
	_, err := db.Delete(req)

	if err != nil {
		response = models.Response{
			Result: "Data delete error.",
		}
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

ハンドラのバインドを書く

作ったDeleteHanlderのバインド処理を書く

main.go

func main() {
	// リスト(todo)の一覧を取得するハンドラのバインド
	http.HandleFunc("/todos", todosHandler)
	// リクエストしたデータを登録するハンドラのバインド
	http.HandleFunc("/register", registerHandler)
	// リクエストしたデータを削除するハンドラのバインド
	http.HandleFunc("/delete", deleteHandler)

	http.ListenAndServe(":8080", nil)
}

Deleteのロジックを書く

Deleteのロジックコードを書く

db.go

/*
指定されたIDのデータを削除する
*/
func Delete(data models.DeleteRequest) (int64, error) {
	db := CreateDBConnection(envVar)
	defer db.Close()

	// クエリ実行
	result, err := db.Exec("DELETE FROM todos WHERE ID = ?", data.ID)
	errorpkg.CheckError(err)

	// 実際に削除した行数を取得する
	rowsAffected, err := result.RowsAffected()
	// 削除行数取得に失敗した場合のエラーを返す
	if err != nil {
		return 0, err
	}

	// 削除した行数が0なら、エラーとして返す
	if rowsAffected == 0 {
		return 0, err
	}

	// 削除した行数を返却する
	return rowsAffected, err
}

テストコードを動かし、mysqlへ接続して、ちゃんと削除されたかを確認する。
確認方法は他の箇所で記載しているため、ここでは省略する。

kip2kip2

リファクタリング

InsertとDeleteでのリターンがタプルとなっていて、一見複雑なので、シンプルな形に変更する。
単純にerrorを返したら良さそうに思うので。

Deleteの変更

書いたばかりのコードを変更するのが容易なため、Deleteから先に変更していく。

db.go

/*
指定されたIDのデータを削除する
*/
func Delete(data models.DeleteRequest) error {
	db := CreateDBConnection(envVar)
	defer db.Close()

	// クエリ実行
	result, err := db.Exec("DELETE FROM todos WHERE ID = ?", data.ID)
	if err != nil {
		return fmt.Errorf("failed to execute delete: %v", err)
	}

	// 実際に削除した行数を取得する
	rowsAffected, err := result.RowsAffected()
	// 削除行数取得に失敗した場合のエラーを返す
	if err != nil {
		return fmt.Errorf("failed to retrieve affected rows: %v", err)
	}

	// 削除した行数が0ならエラーを返す
	if rowsAffected == 0 {
		return fmt.Errorf("no rows deleted, ID %d not found", data.ID)
	}

	// 正常終了のため、nilを返す
	return nil
}

ハンドラ側も変更する。

main.go

/*
リクエストで指定したIDのデータを削除するハンドラ
*/
func deleteHandler(w http.ResponseWriter, r *http.Request) {
	var req models.DeleteRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	response := models.Response{
		Result: "SUCCESS",
	}

	// DBの削除処理を行う
	err := db.Delete(req)

	if err != nil {
		response = models.Response{
			Result: "Data delete error.",
		}
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

処理ができたので、テストコードを起動してテストする。

めっちゃエラーおきた。

エラーの解消に向けて

どうやら、テストコードではIDが1のデータを必ず削除しているが、すでにデータを消しているため、ID
1が存在していないのが問題らしい。

テストコードにID1のデータを登録する処理を追加することで、テストが通るようにする。

テスト用にIDを指定してインサートするコードを追加する

/*
指定したIDのデータをDBにINSERTする
*/
func InsertById(id int, data models.RegisterRequest) (int64, error) {
	db := CreateDBConnection(envVar)
	defer db.Close()

	result, err := db.Exec("INSERT INTO todos (ID, Content, Until) VALUES (?, ?, ?)", id, data.Content, data.Until)
	errorpkg.CheckError(err)

	lastInsertID, err := result.LastInsertId()
	if err != nil {
		return 0, err
	}

	return lastInsertID, err
}

テストコードを変更する。

func TestDeleteHandler(t *testing.T) {
	// deleteテスト用のデータを作成
	untilTime := "2024-12-31"
	untilDate, err := time.Parse("2006-01-02", untilTime)
	errorpkg.CheckError(err)

	testData := models.RegisterRequest{
		Content: "todo test content",
		Until:   untilDate,
	}

	// testデータのインサート
	db.InsertById(1, testData)

	// delete用のリクエストデータを作成
	reqBody := models.DeleteRequest{
		ID: 1,
	}

	jsonData, err := json.Marshal((reqBody))
	if err != nil {
		t.Fatalf("Failed to marshal request: %v", err)
	}

	req, err := http.NewRequest("POST", "/delete", bytes.NewBuffer(jsonData))
	if err != nil {
		t.Fatalf("Failed to delete request: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")

	rr := httptest.NewRecorder()

	handler := http.HandlerFunc(deleteHandler)
	handler.ServeHTTP(rr, req)

	if status := rr.Code; status != http.StatusOK {
		t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK)
	}

	var resBody models.Response
	if err := json.NewDecoder(rr.Body).Decode(&resBody); err != nil {
		t.Fatalf("Failed to decode response: %v", err)
	}

	expectedMessage := "SUCCESS"
	if resBody.Result != expectedMessage {
		t.Errorf("Handler returned unexpected body: got %v want %v", resBody.Result, expectedMessage)
	}
}

テストコードが改修できたので、グリーンバーになることを確認する。

kip2kip2

リファクリング2

こんどはInsert関数を変更する。

Deleteのテスト用に追加したInsetById関数も同様に変更していく。

Insertを変更する

まず呼び出し側のハンドラ関数から変更する。

/*
リクエストに含まれるデータをDBに登録するハンドラ
*/
func registerHandler(w http.ResponseWriter, r *http.Request) {
	var req models.RegisterRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	// 成功の場合のResponseデータを作成
	response := models.Response{
		Result: "SUCCESS",
	}

	// DBへの登録処理を行う
	err := db.Insert(req)
	// DB登録処理が失敗なら、エラーメッセージを格納したResponseデータに変更
	if err != nil {
		response = models.Response{
			Result: "Data register error.",
		}
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)

}

関数側も変更する

db.go

/*
データをDBにINSERTする
*/
func Insert(data models.RegisterRequest) error {
	db := CreateDBConnection(envVar)
	defer db.Close()

	_, err := db.Exec("INSERT INTO todos (Content, Until) VALUES (?, ?)", data.Content, data.Until)
	if err != nil {
		return fmt.Errorf("failed to execute insert: %v", err)
	}

	return nil
}

かなりシンプルになった。

同様にInsertById関数も変更する。

/*
指定したIDのデータをDBにINSERTする
*/
func InsertById(id int, data models.RegisterRequest) error {
	db := CreateDBConnection(envVar)
	defer db.Close()

	_, err := db.Exec("INSERT INTO todos (ID, Content, Until) VALUES (?, ?, ?)", id, data.Content, data.Until)
	if err != nil {
		return fmt.Errorf("failed to execute insert: %v", err)
	}

	return nil
}
kip2kip2

APIの完成

これでAPIのロジックは完成した。

次はフロント部分を作り込んでいく。

kip2kip2

フロントの仕様

フロントの仕様をどうするか。

バックエンドをAPIとして完全に独立して実装したため、フロントはAPIをフェッチする形で実装する必要がある。

とりあえず方針としては以下のようにしたい。

  • スタティックなhtmlを返すエンドポイントを作成し、画面を返す。
  • 画面からJavaScriptを用いて、データをフェッチしてくる。
kip2kip2

ファイルの準備

まずhtmlファイルの準備から。

mkdir static
touch static/index.html
touch static/script.js

上記の操作の結果、以下のディレクトリ構成となる。

.
├── README.md
└── todo-app-go
    ├── go.mod
    ├── go.sum
    ├── internal
    │   ├── db
    │   │   └── db.go
    │   ├── env
    │   │   └── env.go
    │   ├── error
    │   │   └── error.go
    │   └── models
    │       └── todo.go
    ├── main.go
    ├── main_test.go
    └── static
        ├── index.html
        └── script.js

kip2kip2

フロントの準備をする

まず、動作確認のため、簡単なテスト用のコードを記載し、動作を確認する。

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Todo App</title>
</head>
<body>
    <h1>Welcome to Todo App!</h1>
    <p>Hello html!</p>
</body>
</html>

このhtmlをレスポンスできるように、main.goにエンドポイントを追加する。

main.go

func main() {
	http.HandleFunc("/todos", todosHandler)
	http.HandleFunc("/register", registerHandler)

	// 画面を返すエンドポイント
	fs := http.FileServer(http.Dir("./static"))
	http.Handle("/", fs)

	http.ListenAndServe(":8080", nil)
}

これで簡単なhtml画面を返せるようになった。

動作確認

動作確認をする。
まず以下のコマンドでサーバーを立ち上げる。

go run main.go
# これでサーバーが立ち上がり、リッスン状態になる

次に、ブラウザでlocalhost:8080と入力し、アクセスする。

次のような画面が表示されたら成功。

kip2kip2

JavaScriptを追加する

JavaScriptを動かせるようにする。
例のごとく、まずは簡単なコードを動かせる状態にして、動作を確認するところから。

JavaScriptコードを書く

static/script.jsに以下のようなコードを書く。
何をしているかは後ほど説明する。

window.onload = function() {
    document.getElementById('message').textContent = 'Hello from JavaScript!';
};

htmlコードを変更する

htmlコードでJavaScriptを実行するためのコードを書く。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Todo App</title>
    <!-- JavaScriptファイルを読み込み -->
    <script src="script.js" defer></script>
</head>
<body>
    <h1>Welcome to Todo App!</h1>
    <!-- JavaScriptによる書き換えを行うための要素を追加 -->
    <p id="message">This message will be replaced by JavaScript.</p>
</body>
</html>

動作確認

htmlのときと同様に、ブラウザからlocalhost:8080にアクセスする。

JavaScriptが何をしているかというと、

  • index.htmlにある、pタグのidがmessageになっているDOM要素を取得
  • 取得したDOM要素のtextContentに文字列を代入することで置き換える

といった動作を行っている。

なお、DOMについてはフロントをいじると必ず出くわす概念のため、把握しておく必要がある。
DOMがなにかといったところは下記を参照してほしい。

https://zenn.dev/nako_110/articles/db546fa1563547
https://zenn.dev/nash/articles/4490d73b406561
https://zenn.dev/nameless_sn/articles/javascript_dom_tutorial

DOMに関しては調べるといくらでも良質な記事や動画が出てくるので、わかりやすいものを探してそちらを見たほうが理解は早いかも。

kip2kip2

フロントに必要な要素を配置する

フロントで使う画面の要素を配置していく。
動作の実装等は後ほど行う。

  • Todoを追加するためのinputと送信ボタン
  • 現在のTodo一覧を表示する欄
  • Todo一覧では、完了ボタンと削除ボタンを配置する

ここまで書いて、完了ボタンの動作をAPIに実装していなかったことを思い出したので、次のセクションで実装する。

Todo追加用のinputと送信ボタンの設置

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Todo App</title>
    <script src="script.js" defer></script>
</head>
<body>
    <h1>Welcome to Todo App!</h1>
    <div>
        <input placeholder="Input new todo"/>
        <button>送信</button>
    </div>
</body>
</html>

Todo表示欄(一覧)

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Todo App</title>
    <script src="script.js" defer></script>
</head>
<body>
    <h1>Welcome to Todo App!</h1>
    <div>
        <input placeholder="Input new todo"/>
        <button>送信</button>
    </div>
    <div id="todoList">
    </div>
</body>
</html>

これは今の段階では空の要素のみを配置しているため画面には何も表示されないことに注意する。

残りの要素はJavaScriptの処理で設置していくため、画面の要素配置はここまでで終了となる。

kip2kip2

TodoをDoneにした場合の処理を書く

不足していたAPIの処理を書く。
具体的に言うと、TodoをDoneにした場合の処理を書く。

テーブル構成忘れているのと、ログが長くなって参照しづらいため、テーブル構成を再掲。

Doneボタンが押された場合に、紐づくidのデータのDoneをtrueに更新する処理を書いていく。

エンドポイント

エンドポイントは/updateとし、idが入ったJSONをリクエストする形で進めたい。

テストコード

まずテストコードを書く。

/*
updateエンドポイントのテストコード
*/
func TestUpdateHandler(t *testing.T) {
	// deleteテスト用のデータを作成
	untilTime := "2024-12-31"
	untilDate, err := time.Parse("2006-01-02", untilTime)
	errorpkg.CheckError(err)

	testData := models.RegisterRequest{
		Content: "todo test content",
		Until:   untilDate,
	}

	// testデータのインサート
	err = db.InsertById(1, testData)
	if err != nil {
		t.Fatalf("Failed to insert query: %v", err)
	}

	// update用のリクエストデータを作成
	reqBody := models.UpdateRequest{
		ID: 1,
	}

	jsonData, err := json.Marshal((reqBody))
	if err != nil {
		t.Fatalf("Failed to marshal request: %v", err)
	}

	req, err := http.NewRequest("POST", "/update", bytes.NewBuffer(jsonData))
	if err != nil {
		t.Fatalf("Failed to update request: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")

	rr := httptest.NewRecorder()

	handler := http.HandlerFunc(updateHandler)
	handler.ServeHTTP(rr, req)

	if status := rr.Code; status != http.StatusOK {
		t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK)
	}

	var resBody models.Response
	if err := json.NewDecoder(rr.Body).Decode(&resBody); err != nil {
		t.Fatalf("Failed to decode response: %v", err)
	}

	expectedMessage := "SUCCESS"
	if resBody.Result != expectedMessage {
		t.Errorf("Handler returned unexpected body: got %v want %v", resBody.Result, expectedMessage)
	}

	// テストデータ削除用にデータを作成
	deleteId := models.DeleteRequest{
		ID: 1,
	}
	err = db.Delete(deleteId)
	if err != nil {
		t.Fatalf("Failed to delete test data: %v", err)
	}

}

ハンドラの作成

updateリクエストを受け付けるハンドラを作成

main.go

/*
リクエストで指定したIDのデータの状態を更新するハンドラ
*/
func updateHandler(w http.ResponseWriter, r *http.Request) {
	var req models.UpdateRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	response := models.Response{
		Result: "SUCCESS",
	}

	// データの更新を行う
	err := db.Update(req)

	if err != nil {
		response = models.Response{
			Result: "Data update error.",
		}
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

バインドも追加する

main.go

func main() {
	// リスト(todo)の一覧を取得するハンドラのバインド
	http.HandleFunc("/todos", todosHandler)
	// リクエストしたデータを登録するハンドラのバインド
	http.HandleFunc("/register", registerHandler)
	// リクエストしたデータを削除するハンドラのバインド
	http.HandleFunc("/delete", deleteHandler)
	// リクエストしたデータを更新するハンドラのバインド
	http.HandleFunc("/update", updateHandler)

	// 画面を返すエンドポイント
	fs := http.FileServer(http.Dir("./static"))
	http.Handle("/", fs)

	http.ListenAndServe(":8080", nil)
}

DBとの処理を書く

DBと実際に処理するUpdate関数を書く

db.go

/*
指定したIDのデータを更新する。
*/
func Update(data models.UpdateRequest) error {
	db := CreateDBConnection(envVar)
	defer db.Close()

	// クエリ実行
	result, err := db.Exec("UPDATE todos SET Done = IF(Done = 1, 0, 1) WHERE id = ?", data.ID)
	if err != nil {
		return fmt.Errorf("failed to execute update: %v", err)
	}

	// 実際に更新した行数を取得する
	rowsAffected, err := result.RowsAffected()
	// 更新した行数取得に失敗した場合のエラーを返す
	if err != nil {
		return fmt.Errorf("failed to retrieve affected rows: %v", err)
	}

	// 更新した行数が0ならエラーを返す
	if rowsAffected == 0 {
		return fmt.Errorf("no rows updated, ID %d not found", data.ID)
	}

	// 正常終了のため、nilを返す
	return nil
}

動作確認

テストコードを実行してグリーンバーになっていることを確認できたらOK

kip2kip2

リファクタリング

テストコードでIDを1としてハードコードしてしまっている。
これを動的に変更したい。

方針

  • IDを指定しないinsertの場合に、IDを返すようにする
  • 返されたIDを使用して、データの確認、削除などを行う

実装

あちこちいじる必要がある。
こういった場合はVSCodeのエラー表示をうまく使う。

まず変えたい場所のコードを変更する。
この場合はテストコードの以下の箇所を以下のように変える。

	// testデータのインサート
	lastInsertID, err := db.Insert(testData)
	if err != nil {
		t.Fatalf("Failed to insert test data: %v", err)
	}

すると赤波線でコンパイル時エラーになる箇所を教えてくれる。

こんな感じ。

このエラーをうけて、コードを修正していく。

Insert関数の修正

db.go

/*
データをDBにINSERTする
*/
func Insert(data models.RegisterRequest) (int64, error) {
	db := CreateDBConnection(envVar)
	defer db.Close()

	result, err := db.Exec("INSERT INTO todos (Content, Until) VALUES (?, ?)", data.Content, data.Until)
	if err != nil {
		return 0, fmt.Errorf("failed to execute insert: %v", err)
	}

	lastInsertID, err := result.LastInsertId()
	if err != nil {
		return 0, fmt.Errorf("failed to retrieve last insert ID: %v", err)
	}

	return lastInsertID, nil
}

これでInsert関数は期待する処理に変更できた。

Insert関数呼び出し側を変更する。

あとは呼び出し側の処理を変えていく必要があるので、そこを変更していく。

VSCodeのエクスプローラーで真っ赤っ赤になっているのファイルが、エラーが発生しているファイルとなる。

以上を一つ一つ確認して潰していくと良い。

今回でいうと、

  • Insertの返り値が2つになっているので、1つで処理していた箇所の記載を変更する。

対象のファイルは2つ。
まずはmain.goから。

main.go

/*
リクエストに含まれるデータをDBに登録するハンドラ
*/
func registerHandler(w http.ResponseWriter, r *http.Request) {
	var req models.RegisterRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	// 成功の場合のResponseデータを作成
	response := models.Response{
		Result: "SUCCESS",
	}

	// DBへの登録処理を行う
	_, err := db.Insert(req)
	// DB登録処理が失敗なら、エラーメッセージを格納したResponseデータに変更
	if err != nil {
		response = models.Response{
			Result: "Data register error.",
		}
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)

}

テストコードの変更

本命であるテストコードの変更を行う。

main_test.go

/*
updateエンドポイントのテストコード
*/
func TestUpdateHandler(t *testing.T) {
	// deleteテスト用のデータを作成
	untilTime := "2024-12-31"
	untilDate, err := time.Parse("2006-01-02", untilTime)
	errorpkg.CheckError(err)

	testData := models.RegisterRequest{
		Content: "todo test content",
		Until:   untilDate,
	}

	// testデータのインサート
	lastInsertID, err := db.Insert(testData)
	if err != nil {
		t.Fatalf("Failed to insert test data: %v", err)
	}

	// 更新前のデータを取得
	originalTodo := db.SelectById(int(lastInsertID))

	// update用のリクエストデータを作成
	reqBody := models.UpdateRequest{
		ID: int(lastInsertID),
	}

	jsonData, err := json.Marshal((reqBody))
	if err != nil {
		t.Fatalf("Failed to marshal request: %v", err)
	}

	req, err := http.NewRequest("POST", "/update", bytes.NewBuffer(jsonData))
	if err != nil {
		t.Fatalf("Failed to update request: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")

	rr := httptest.NewRecorder()

	handler := http.HandlerFunc(updateHandler)
	handler.ServeHTTP(rr, req)

	if status := rr.Code; status != http.StatusOK {
		t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK)
	}

	var resBody models.Response
	if err := json.NewDecoder(rr.Body).Decode(&resBody); err != nil {
		t.Fatalf("Failed to decode response: %v", err)
	}

	expectedMessage := "SUCCESS"
	if resBody.Result != expectedMessage {
		t.Errorf("Handler returned unexpected body: got %v want %v", resBody.Result, expectedMessage)
	}

	// 更新が実際に行われたかをDBから確認する
	updatedTodo := db.SelectById(int(lastInsertID))
	if updatedTodo.Done == originalTodo.Done {
		t.Errorf("Todo 'Done' field was not updated: got %v, expected different value from %v", updatedTodo.Done, originalTodo.Done)
	}

	// テストデータ削除用にデータを作成
	deleteId := models.DeleteRequest{
		ID: int(lastInsertID),
	}
	err = db.Delete(deleteId)
	if err != nil {
		t.Fatalf("Failed to delete test data: %v", err)
	}

}

あとは動作確認してグリーンバーになるのを見届けたらOK。

Deleteエンドポイントのテストコードも変更

DeleteエンドポイントのテストコードもIDのハードコーディングしているので、こちらも変更した。

コードなどは煩雑になるので、省略する。

kip2kip2

APIのエンドポイントを変更する

ここに来て気づいたが、フロント部をも同じサーバーから配信するのであれば、APIのエンドポイントを変えたほうがいい。
/api/endpointの形式としたい。

というわけで各種変更していく。
といっても、main.goとテストコードで呼び出ししている箇所を変えるだけ。

main.goの変更点のみ示す。

func main() {
	// リスト(todo)の一覧を取得するハンドラのバインド
	http.HandleFunc("/api/todos", todosHandler)
	// リクエストしたデータを登録するハンドラのバインド
	http.HandleFunc("/api/register", registerHandler)
	// リクエストしたデータを削除するハンドラのバインド
	http.HandleFunc("/api/delete", deleteHandler)
	// リクエストしたデータを更新するハンドラのバインド
	http.HandleFunc("/api/update", updateHandler)

	// 画面を返すエンドポイント
	fs := http.FileServer(http.Dir("./static"))
	http.Handle("/", fs)

	http.ListenAndServe(":8080", nil)
}
kip2kip2

フロント処理を追加する

フロントの処理を追加していく。
APIとしてエンドポイントを作成してDBとの処理は実装されているので、あとはフロントからフェッチする処理が必要。

なお、fetchするのにPromiseという概念が使われるので、参考文献をぺたり。

参考文献
https://qiita.com/cheez921/items/41b744e4e002b966391a

kip2kip2

Todoの一覧表示

Todoの一覧を表示する処理を書く。

例の如く小さく進めていく。

fetchする処理まで書く

まずはエンドポイントからデータを取得できるかを試す。

const apiTodoListEndpoint = "http://localhost:8080/api/todos"

fetch(apiTodoListEndpoint)
    .then(response => {
        if (!response.ok) {
            throw new Error("Network response was not ok " + response.statusText)
        }
        return response.json()
    })
    .then(data => {
        console.log(data)
    })
    .catch(error => {
        console.error("There was a problem with the fetch operation:", error)
    })

動作確認

動作の確認はブラウザから行う。

まずブラウザを開いて、http://localhost:8080にアクセスする。

次に、開発者ツールを開いて、コンソールタブを開く。

参考記事
https://qiita.com/nakamura_s/items/2aa1481a76cf820ea618

アクセスしてコンソールタブを見ると、データが取得できているのが確認できる。

以下の画像は例。

kip2kip2

取得したデータをフロントに反映する

データが取得できるのは確認できたので、それをフロントに反映していく。

一覧で表示できるようにする

とりあえずJSONのContentのみを取得して表示してみる。

const apiTodoListEndpoint = "http://localhost:8080/api/todos"

fetch(apiTodoListEndpoint)
    .then(response => {
        if (!response.ok) {
            throw new Error("Network response was not ok " + response.statusText)
        }
        return response.json()
    })
    .then(data => {
        data.forEach(todo => {
            displayTodo(todo)
        });
    })
    .catch(error => {
        console.error("There was a problem with the fetch operation:", error)
    })

function displayTodo(todo) {
    const todoList = document.getElementById("todoList")

    // div要素を作成
    const todoItem = document.createElement("div")
    // classとしてtodoItemを追加
    todoItem.classList.add("todoItem")

    // Contentの表示
    const contentElement = document.createElement("p")
    contentElement.textContent = todo.Content
    todoItem.appendChild(contentElement)

    // /Todoの要素をtodoListに追加
    todoList.appendChild(todoItem)
}

kip2kip2

要素を追加していく

表示できるのは分かったので、ここから更に要素を追加していく。

Doneボタンの追加

要素ごとにDoneボタンを追加する。

function displayTodo(todo) {
    const todoList = document.getElementById("todoList")

    // div要素を作成
    const todoItem = document.createElement("div")
    // classとしてtodoItemを追加
    todoItem.classList.add("todoItem")

    // Contentの表示
    const contentElement = document.createElement("p")
    contentElement.textContent = todo.Content
    todoItem.appendChild(contentElement)

    // Doneボタンを表示
    const doneButton = document.createElement("button")
    doneButton.textContent = !todo.Done ? "終了" : "戻す"
    doneButton.onclick = function() {
        // onclickは仮置き
        // 後ほどAPIへのフェッチ処理に変更する
        alert("Todo is marked as done!")
    }
    todoItem.appendChild(doneButton)

    // /Todoの要素をtodoListに追加
    todoList.appendChild(todoItem)
}

三項演算子という記法を使っている。

参考文献
https://qiita.com/H40831/items/8b5fb7e936e5fcb730ca

kip2kip2

デザインを整える

ボタンが説明文のしたに来ており、非常に見づらい。
なので、ちょっとだけデザインを整える。

flex-boxを使って、横に並べるだけのデザイン変更。

まずindex.htmlに、CSSの読み込みを追加する。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Todo App</title>
    <script src="script.js" defer></script>
    <!-- cssの読み込み処理 -->
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <h1>Welcome to Todo App!</h1>
    <div>
        <input placeholder="Input new todo"/>
        <button>送信</button>
    </div>
    <div id="todoList">
    </div>
</body>
</html>

CSSを記載する

次に、CSSを記載する。

まずはファイルを作成する。

例はコマンドでの作成だが、GUIを用いて作成しても良い。

touch static/styles.css

そして、CSSを以下のように記載する。

.todoItem {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 10px;
    border: 1px solid #ddd;
    margin-top: 10px;
}

.todoItem p {
    margin: 0;
}

動作確認

あとはブラウザをリロードすれば画像の様になるはずである。

kip2kip2

Deleteボタンの設置

Deleteボタンを設置してみる。
Doneボタンを同じように追加してくだけ。

function displayTodo(todo) {
    const todoList = document.getElementById("todoList")

    // div要素を作成
    const todoItem = document.createElement("div")
    // classとしてtodoItemを追加
    todoItem.classList.add("todoItem")

    // Contentの表示
    const contentElement = document.createElement("p")
    contentElement.textContent = todo.Content
    todoItem.appendChild(contentElement)

    // Doneボタンを表示
    const doneButton = document.createElement("button")
    doneButton.textContent = !todo.Done ? "終了" : "戻す"
    doneButton.onclick = function() {
        // onclickは仮置き
        // 後ほどAPIへのフェッチ処理に変更する
        alert("Todo is marked as done!")
    }
    todoItem.appendChild(doneButton)

    // Deleteボタンの設置
    const deleteButton = document.createElement("button")
    deleteButton.textContent = "削除"
    deleteButton.onclick = function() {
        // onclickは仮置き
        alert("Click delete button!")
    }
    todoItem.appendChild(deleteButton)

    // /Todoの要素をtodoListに追加
    todoList.appendChild(todoItem)
}

おや、レイアウトが崩れているな。

レイアウトを微調整する

崩れたレイアウトを微調整する。

具体的には、Todoの説明文の真横にボタンが来るようにしたい。

.todoItem {
    display: flex;
    align-items: center;
    padding: 10px;
    border: 1px solid #ddd;
    margin-top: 10px;
}

.todoItem p {
    margin: 0;
    margin-right: 20px;
}

.todoItem button {
    margin-right: 20px;
}

こんな感じでどうだろう。

いい感じ。

kip2kip2

リファクタリング

JavaScriptのdisplayTodo()関数が長くなってきたので、分割できるところを分割しようと思う。

Doneボタン生成コードの切り出し

Doneボタン生成コードを関数に切り出す。

まず、呼び出し側を以下の様に変更しておく。
コメントアウトしたところが変更した箇所になる。
なお、ボタンに表示する文字列をちょっと変えている。

script.js

    // Doneボタンを表示
    // const doneButton = document.createElement("button")
    // doneButton.textContent = !todo.Done ? "タスク完了" : "未完了に戻す"
    // doneButton.onclick = function() {
         // onclickは仮置き
         // 後ほどAPIへのフェッチ処理に変更する
    //     alert("Todo is marked as done!")
    // }
    const doneButton = createDoneButton(todo.Done)
    todoItem.appendChild(doneButton)

呼び出しの一行にまとまった。

あとは、この関数の実体を別の関数として書き出せばいい

function createDoneButton (isDone) {
    const doneButton = document.createElement("button")
    doneButton.textContent = isDone ? "タスク完了" : "未完了に戻す"
    doneButton.onclick = function() {
        // onclickは仮置き
        // 後ほどAPIへのフェッチ処理に変更する
        alert("Todo is marked as done!")
    }

    return doneButton
}

動作確認は省略するが、ブラウザで変更前と同様に動作すればOK

Deleteボタン生成コードの切り出し

Deleteボタンについても同様に行おう。
まず呼び出し側から変更する。

    // Deleteボタンの設置
    const deleteButton = createDeleteButton()
    // const deleteButton = document.createElement("button")
    // deleteButton.textContent = "削除"
    // deleteButton.onclick = function() {
    //     alert("Click delete button!")
    // }
    todoItem.appendChild(deleteButton)

呼び出す対象の関数を書く。

function createDeleteButton () {
    const deleteButton = document.createElement("button")
    deleteButton.textContent = "削除"
    deleteButton.onclick = function() {
        // onclickは仮置き
        alert("Click delete button!")
    }
    return deleteButton
}

動作確認は行っているが、ログでは省略。

意図

コードを関数に切り出した意図としては

  • ボタンを一つのコンポーネントとして捉える事ができる。長いコードの一部としてのボタンではなく、ボタンのコードだけに集中できる。
  • ボタンのonClickにボタンの動きを記載するため、切り出していたほうが変更しやすい。

という感じ。

kip2kip2

Deleteボタンの処理を追加する

Deleteボタンを押したときの動作を追加しよう。

必要な機能としては以下のようになる。

  • APIサーバーにDeleteをリクエストする必要がある
  • DeleteのリクエストはIDを指定して行う
  • リクエストに使うのデータはJSON
  • APIサーバーからのレスポンスを受けて、削除するかしないかを分岐させる
  • レスポンスが「成功」なら、IDに紐づいているDOM要素を削除する
  • レスポンスが「失敗」やエラーなら、ポップアップして削除が失敗した旨を表示する

さて、一つ一つ実装していこう。

作業の選定

以上のように、機能がわかった段階で、「何を実装するのが一番簡単か」というのは自問するといい。

今回の場合でいうと、簡単なところはなんだろうか?

APIサーバーへのリクエスト?
いや、難しい。

条件分岐のコード?
いや、APIサーバーへのリクエストを作らないと書けない。(書けるが、モックなどを作る必要があるので、めんどう)

ボタンを押したらDOM要素を削除する?
うん、これは簡単そうである。

というわけで、「削除ボタンを押したら、idに紐づく要素が削除されるようにする」という処理を書いていく。

IDに紐づくDOM要素の削除

IDをもらったら削除するボタンを作るので、createDeleteButtonに引数を追加する。

まず呼び出し元を変更。

    // Deleteボタンの設置
    const deleteButton = createDeleteButton(todo.ID)
    todoItem.appendChild(deleteButton)

次に、関数を変更する。

function createDeleteButton (id) {
    const deleteButton = document.createElement("button")
    deleteButton.textContent = "削除"
    deleteButton.onclick = function() {
        removeTodoItem(id)
    }
    return deleteButton
}

removeTodoItemという架空の関数の呼び出しに変更している。
この関数を呼び出せば、IDに紐づくTodoが削除されるという寸法。

というわけで、その関数の中身を書いていこう。

function removeTodoItem(id) {
    const todoItem = document.querySelector(`[data-id='${id}']`)
    if (todoItem) {
        todoItem.remove()
    }
}

動作確認

削除ボタンで要素が削除される確認してみよう。
なお、削除しているだけでDBには反映されてないので、ブラウザでページの更新をしたらタスクは元に戻ることに注意する。

関数名変更

次の処理を書いていくにあたって、関数名を変更する。

function removeTodoElement(id) {
    const todoItem = document.querySelector(`[data-id='${id}']`)
    if (todoItem) {
        todoItem.remove()
    }
}

Elementにしたのは、「TodoItemの削除」と「DOM要素としてのTodoItemの削除」を分けたかったから。

呼び出し元も、以下の形に変更する。

function createDeleteButton (id) {
    const deleteButton = document.createElement("button")
    deleteButton.textContent = "削除"
    deleteButton.onclick = function() {
        deleteTodoItem(id)
    }
    return deleteButton
}

deleteTodoItemの中身を作る

さて、内容を作っていこう。
まずは、フェッチ処理(APIサーバーへのリクエスト)から。

最初は簡単に、リクエストが成功かどうかにかかわらず、削除する処理を書く。

まず、グローバルな定数として、APIのエンドポイントを定義する。
script.jsの頭に書く(ファイルの1行目とか2行目とか)。
関数内に直接ハードコーディングしない理由は、あとで変更が容易だから。APIのエンドポイントを変更したと思ったときに、ファイルの先頭に書いてあるものを変更したらいいとわかるので。

const apiDeleteEndpoint = "http://localhost:8080/api/delete"

さて、これを受けて、関数の処理を書こう。

function deleteTodoItem(id) {
    // リクエストする対象のIDをJSONに詰める
    const requestData = {
        ID: id
    }

    // /api/deleteに対して、リクエストを送る
    fetch(apiDeleteEndpoint, {
        method: "DELETE",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify(requestData)
    })
    .then(() => {
        removeTodoElement(id)
    })
}

これで、削除リクエストがAPIに送信されるので、DBのデータが削除されるようになった。

動作確認は省略するが、以下の点を確認すると良いと思う。

  • 削除ボタンを押して、削除されることを確認する。その後、ブラウザを更新し、削除したタスクがちゃんと削除されることを確認する。
  • MySQLクライアントからDBに接続し、削除前のデータを確認したあと、削除ボタンを押して、DBからデータが消えたかどうかを確認する。

レスポンスが成功かどうかで処理を分岐する

あとはレスポンスにより動作を分岐する処理を書けばいい。

function deleteTodoItem(id) {
    // リクエストする対象のIDをJSONに詰める
    const requestData = {
        ID: id
    }

    // /api/deleteに対して、リクエストを送る
    fetch(apiDeleteEndpoint, {
        method: "DELETE",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify(requestData)
    })
    .then((response) => {
        // responseがエラーの場合
        if (!response.ok) {
            throw new Error("Failed to delete Todo item")
        }
        return response.json()
    })
    .then(responseJson => {
        // responseで返ってきたJSONの内容により処理を分岐
        // 成功の場合
        if (responseJson.result === "SUCCESS") {
            removeTodoElement(id)
        // 失敗の場合
        } else {
            alert(`Error: ${responseJson.result}`)
        }
    })
    // フェッチそのもののエラーをキャッチする
    .catch(error => {
        console.error("There was a problem with the fetch operation:", error)
    })

}
kip2kip2

APIのドキュメント化

フロント開発を進める前に、APIのドキュメント化を行う。

今の段階では、フロントを開発するときにバックエンドのソースを読みながら行っているので、非常に非効率。
というかめんどくさい。

ツールの選定

SwaggerUIを使いたいと思う。
go用のライブラリがあったので、それを使用する。

リポジトリ
https://github.com/swaggo/swag

参考記事
https://zenn.dev/toraco/articles/858a9b2fe72508

インストール

まずはインストールしておく。

go get -u github.com/swaggo/swag/cmd/swag
go get -u github.com/swaggo/http-swagger

なお、公式のリポジトリの手順と若干違ってhttp-swaggerがあるのは、Goサーバー上で Swagger UIをホスティングするために使用する。

インストールの失敗

上記のコマンドでインストールが失敗しているらしい。
バージョンコマンドで動作確認をしたが、反応が無い。

swag --version
zsh: command not found: swag

色々調べたところ、以下の対処でいいとわかったので、再度インストール。

go install github.com/swaggo/swag/cmd/swag@latest
# このコマンドでバイナリインストール先が存在するかを確認する。
go env GOPATH
# ターミナルで移動して、swagが存在するのも確認したので、以下のようのコマンドでパスを設定する
export PATH=$PATH:$(go env GOPATH)/bin

# なお、コマンドパスの永続化についても行う。
# mac環境のため、zshrcに設定する。
vim ~/.zshrc
# .zshrcに書き込んで保存すれば完了

上記のコマンドを行った結果、バージョンが確認できるようになったので、インストール成功。

swag --version
swag version v1.16.3

試しにUpdate用のハンドラのみをドキュメント化

さて、まずひとつのハンドラから試して動作を確認する。
まず、updateHandlerメソッドの頭に、swaggo用のドキュメントを記載する。

main.go

// updateHandler godoc
// @Summary Update a todo
// @Description Update the status of a todo by ID
// @Tags todos
// @Accept json
// @Produce json
// @Param updateRequest body models.UpdateRequest true "Update Todo"
// @Success 200 {object} models.Response
// @Failure 400 {object} models.Response
// @Failure 500 {object} models.Response
// @Router /api/update [put]
/*
リクエストで指定したIDのデータの状態を更新するハンドラ
*/
func updateHandler(w http.ResponseWriter, r *http.Request) {

次に、呼び出し側のmain関数に、ドキュメントの記載と、swaggerの確認用のエンドポイントを定義する。

main.go

// @title Go Todo API
// @version 1.0
// @description This is a sample Todo API.
// @host localhost:8080
// @BasePath /api
func main() {
	// swaggerドキュメントの設定
	http.HandleFunc("/swagger/", httpSwagger.WrapHandler)

	// リスト(todo)の一覧を取得するハンドラのバインド
	http.HandleFunc("/api/todos", todosHandler)
	// リクエストしたデータを登録するハンドラのバインド
	http.HandleFunc("/api/register", registerHandler)
	// リクエストしたデータを削除するハンドラのバインド
	http.HandleFunc("/api/delete", deleteHandler)
	// リクエストしたデータを更新するハンドラのバインド
	http.HandleFunc("/api/update", updateHandler)

	// 画面を返すエンドポイント
	fs := http.FileServer(http.Dir("./static"))
	http.Handle("/", fs)

	http.ListenAndServe(":8080", nil)
}

最後に、インポート文を追加する。
swaggo用のもの、それから、公開するSwaggerUIのドキュメントのパスを指定する。

import (
	"encoding/json"
	"net/http"
	"todoApp/internal/db"
	"todoApp/internal/models"

	_ "todoApp/docs"

	"github.com/swaggo/http-swagger"
)

コマンドラインから以下のコマンドを入力してドキュメントを生成する。

swag init

さて、動作確認のためにサーバーを立ち上げ、ブラウザからhttp://localhost:8080/swagger/index.htmlにアクセスする。

go run main.go

展開するとこんな感じ

kip2kip2

ドキュメントの変更

日本語の方が読みやすいので、説明文を変更する。

// @title Go Todo API
// @version 1.0
// @description TodoアプリのバックエンドAPIです。
// @host localhost:8080
// @BasePath /api
func main() {
	// swaggerドキュメントの設定
	http.HandleFunc("/swagger/", httpSwagger.WrapHandler)

	// リスト(todo)の一覧を取得するハンドラのバインド
	http.HandleFunc("/api/todos", todosHandler)
	// リクエストしたデータを登録するハンドラのバインド
	http.HandleFunc("/api/register", registerHandler)
	// リクエストしたデータを削除するハンドラのバインド
	http.HandleFunc("/api/delete", deleteHandler)
	// リクエストしたデータを更新するハンドラのバインド
	http.HandleFunc("/api/update", updateHandler)

	// 画面を返すエンドポイント
	fs := http.FileServer(http.Dir("./static"))
	http.Handle("/", fs)

	http.ListenAndServe(":8080", nil)
}

// updateHandler godoc
// @Summary IDに紐づくTodoのDoneを更新する
// @Description Update the status of a todo by ID
// @Tags todos
// @Accept json
// @Produce json
// @Param updateRequest body models.UpdateRequest true "Update Todo"
// @Success 200 {object} models.Response
// @Failure 400 {object} models.Response
// @Failure 500 {object} models.Response
// @Router /api/update [put]
/*
リクエストで指定したIDのデータの状態を更新するハンドラ
*/
func updateHandler(w http.ResponseWriter, r *http.Request) {
// 以下略

ビルドのし直しをして、再度アクセスする

# ビルドし直し
swag init

kip2kip2

残ったswaggo用のドキュメントを書く

残ったエンドポイントのドキュメントを記載する。

ソースコードが長いが、記載が終わったものが以下になる

package main

import (
	"encoding/json"
	"net/http"
	"todoApp/internal/db"
	"todoApp/internal/models"

	_ "todoApp/docs"

	httpSwagger "github.com/swaggo/http-swagger"
)

// @title Go Todo API
// @version 1.0
// @description TodoアプリのバックエンドAPIです。
// @host localhost:8080
// @BasePath /api
func main() {
	// swaggerドキュメントの設定
	http.HandleFunc("/swagger/", httpSwagger.WrapHandler)

	// リスト(todo)の一覧を取得するハンドラのバインド
	http.HandleFunc("/api/todos", todosHandler)
	// リクエストしたデータを登録するハンドラのバインド
	http.HandleFunc("/api/register", registerHandler)
	// リクエストしたデータを削除するハンドラのバインド
	http.HandleFunc("/api/delete", deleteHandler)
	// リクエストしたデータを更新するハンドラのバインド
	http.HandleFunc("/api/update", updateHandler)

	// 画面を返すエンドポイント
	fs := http.FileServer(http.Dir("./static"))
	http.Handle("/", fs)

	http.ListenAndServe(":8080", nil)
}

// updateHandler godoc
// @Summary IDに紐づくTodoのDoneを更新する
// @Description IDに紐づいているTodoのステータスを更新する。呼び出す度に、Doneのステータスをトグルする。
// @Tags todos
// @Accept json
// @Produce json
// @Param updateRequest body models.UpdateRequest true "Update Todo"
// @Success 200 {object} models.Response
// @Failure 400 {object} models.Response
// @Failure 500 {object} models.Response
// @Router /api/update [put]
/*
リクエストで指定したIDのデータの状態を更新するハンドラ
*/
func updateHandler(w http.ResponseWriter, r *http.Request) {
	var req models.UpdateRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	response := models.Response{
		Result: "SUCCESS",
	}

	// データの更新を行う
	err := db.Update(req)

	if err != nil {
		response = models.Response{
			Result: "Data update error.",
		}
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

// Handler godoc
// @Summary IDに紐づくTodoを削除する
// @Description IDに紐づくTodoをDBから削除する
// @Tags todos
// @Accept json
// @Produce json
// @Param deleteRequest body models.DeleteRequest true "Delete Todo"
// @Success 200 {object} models.Response
// @Failure 400 {object} models.Response
// @Failure 500 {object} models.Response
// @Router /api/delete [delete]
/*
リクエストで指定したIDのデータを削除するハンドラ
*/
func deleteHandler(w http.ResponseWriter, r *http.Request) {
	var req models.DeleteRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	response := models.Response{
		Result: "SUCCESS",
	}

	// DBの削除処理を行う
	err := db.Delete(req)

	if err != nil {
		response = models.Response{
			Result: "Data delete error.",
		}
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

// registerhandler godoc
// @Summary TodoをDBに登録する
// @Description リクエストに含まれるTodoのデータをDBに登録する
// @Tags todos
// @Accept json
// @Produce json
// @Param registerRequest body models.RegisterRequest true "Register Todo"
// @Success 200 {object} models.Response
// @Failure 400 {object} models.Response
// @Failure 500 {object} models.Response
// @Router /api/register [post]
/*
リクエストに含まれるデータをDBに登録するハンドラ
*/
func registerHandler(w http.ResponseWriter, r *http.Request) {
	var req models.RegisterRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	// 成功の場合のResponseデータを作成
	response := models.Response{
		Result: "SUCCESS",
	}

	// DBへの登録処理を行う
	_, err := db.Insert(req)
	// DB登録処理が失敗なら、エラーメッセージを格納したResponseデータに変更
	if err != nil {
		response = models.Response{
			Result: "Data register error.",
		}
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)

}

// todosHandler godoc
// @Summary Todoのリストを取得する
// @Description DBに登録されているすべてのTodoをリストで取得する
// @Tags todos
// @Accept json
// @Produce json
// @Success 200 {object} models.Todo
// @Failure 400 {object} models.Todo
// @Failure 500 {object} models.Todo
// @Router /api/todos [get]
/*
DBからTodoの全リストを取得して、レスポンスするハンドラ
*/
func todosHandler(w http.ResponseWriter, r *http.Request) {
	todos := db.SelectAll()

	w.Header().Set("Content-Type", "application/json")

	err := json.NewEncoder(w).Encode(todos)
	if err != nil {
		http.Error(w, "Filed to encode users", http.StatusInternalServerError)
		return
	}
}

完成したものがこちら

これでAPIの仕様が確認しやすくなった。

kip2kip2

UPDATE

TodoのDONEボタンを押したときの処理を作る。
具体的に言うと、「未完了に戻す」となっているボタンの挙動。

呼び出し側を変更する

まず呼び出し側を変更する。
PUTリクエストに必要な情報を関数に渡す必要があるので、まずはAPIの仕様から調査。

http://localhost:8080/swagger/index.htmlにブラウザからアクセスし、PUTの仕様を確認。

JSONでidのみをリクエストすることがわかる。

なので、関数にIDを渡し、そのIDを元にPUTリクエストを行うように変更する。

まずは呼び出し側を変更する。

-     // Doneボタンを表示
-  const doneButton = createDoneButton(todo.Done)

+    // Doneボタンを表示
+ const doneButton = createDoneButton(todo.ID, todo.Done)

関数側を変更する

関数の処理を変更していこう。

必要なことは以下になる。

  • deleteと同様に、updateエンドポイントにフェッチを行う
  • フェッチに成功した場合に、ボタン文字列のトグルを行う

要するに、既存のボタン文字列のトグル処理を、フェッチの成功の場合に行う形に変更すればよい。

deleteのときのコードをコピペしてきて、必要な箇所を変更していく。

まずcreateDoneButtonの方から

function createDoneButton (id, isDone) {

    const doneButton = document.createElement("button")
    doneButton.textContent = isDone ? "タスク完了" : "未完了に戻す"
    doneButton.onclick = function() {
-        // onclickは仮置き
-      // 後ほどAPIへのフェッチ処理に変更する
-        alert("Todo is marked as done!")
+        updateDoneButton(id)

    }

    return doneButton
}

ちょっと仕様を誤解していたので、以下のように記載を変更。
ついでに、再利用が可能なようにテキストは定数として宣言している。

const taskDoneButtonText = "未完了に戻す"
const taskNotDoneButtonText = "タスク完了"

function createDoneButton (id, isDone) {
    const doneButton = document.createElement("button")
    doneButton.textContent = isDone ? taskDoneButtonText : taskNotDoneButtonText
    doneButton.onclick = function() {
        updateDoneButton(id)
    }

    return doneButton
}

さて、中身となるupdateDoneButtonを実装していく。
基本的にはdeleteTodoItem関数と同じ。
成功時に呼び出す関数のみが違う。

function updateDoneButton(id) {
    // リクエストする対象のIDをJSONに詰める
    const requestData = {
        ID: id
    }

    // /api/deleteに対して、リクエストを送る
    fetch(apiUpdateEndpoint, {
        method: "PUT",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify(requestData)
    })
    .then((response) => {
        // responseがエラーの場合
        if (!response.ok) {
            throw new Error("Failed to update Todo item")
        }
        return response.json()
    })
    .then(responseJson => {
        // responseで返ってきたJSONの内容により処理を分岐
        // 成功の場合
        if (responseJson.result === "SUCCESS") {
            toggleUpdateButton(id)
        // 失敗の場合
        } else {
            alert(`Error: ${responseJson.result}`)
        }
    })
    // フェッチそのもののエラーをキャッチする
    .catch(error => {
        console.error("There was a problem with the fetch operation:", error)
    })
}

さて、成功時に呼び出す関数の内容を実装しよう。


function toggleUpdateButton(id) {
    const todoItem = document.querySelector(`[data-id='${id}']`)
    if (todoItem) {
        const doneButton = todoItem.querySelector("button")

        if (doneButton) {
            if(doneButton.textContent === taskDoneButtonText) {
                doneButton.textContent = taskNotDoneButtonText
            } else if (doneButton.textContent === taskNotDoneButtonText) {
                doneButton.textContent = taskDoneButtonText
            }
        }
    } 
}

トグルしているのが確認できる。

DBの値も変更しているのがわかる。(一番上のレコードのDoneカラム)

kip2kip2

POSTを実装する

POST処理を実装する。
具体的に言うと、Todoの登録のリクエストを実装する。

APIのリクエスト仕様を確認

POSTのリクエスト仕様を確認する。

untilは一応用意していたが、実は使わないカラムとなっている。

拡張してもよいが、この段階では使わないので削除する。

untilの削除

untilカラムを削除するための変更を行っていく。

やるタスクは以下になる。

  • DBからuntilカラムを削除する
  • APIの処理からuntilを除く

DBからuntilカラムを削除する

これは簡単である。
カラムの削除をSQLで実行すればいい。

mysqlにログインして、以下のSQL文を実行する。

ALTER TABLE todos DROP COLUMN until;
mysql> DESCRIBE todos;
+-----------+--------------+------+-----+-------------------+-----------------------------------------------+
| Field     | Type         | Null | Key | Default           | Extra                                         |
+-----------+--------------+------+-----+-------------------+-----------------------------------------------+
| ID        | int          | NO   | PRI | NULL              | auto_increment                                |
| Content   | varchar(255) | NO   |     | NULL              |                                               |
| Done      | tinyint(1)   | NO   |     | 0                 |                                               |
| CreatedAt | datetime     | NO   |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED                             |
| UpdatedAt | datetime     | NO   |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP |
+-----------+--------------+------+-----+-------------------+-----------------------------------------------+
5 rows in set (0.01 sec)

Untilカラムが削除されているのが確認できる。

APIの処理からuntilを除く

APIの方を修正していこう。

まず型定義から削除する。
todo.go

type RegisterRequest struct {
	Content string `db:"Content"`
-	Until   time.Time `db:"Until"`
}

type Todo struct {
	ID        int       `db:"ID"`
	Content   string    `db:"Content"`
	Done      bool      `db:"Done"`
-	Until     *time.Time `db:"Until"`
	CreatedAt time.Time `db:"CreatedAt"`
	UpdatedAt time.Time `db:"UpdatedAt"`
}

次に上記の2つの構造体に関係する箇所を修正していく。

VSCodeを使っていると、赤い色になっているファイルが修正対象なので、わかりやすいと思う。

変更した箇所のみを示す。

db.go

/*
データをDBにINSERTする
*/
func Insert(data models.RegisterRequest) (int64, error) {
	db := CreateDBConnection(envVar)
	defer db.Close()

-	result, err := db.Exec("INSERT INTO todos (Content, Until) VALUES (?, ?)", data.Content, data.Until)
+	result, err := db.Exec("INSERT INTO todos (Content) VALUES (?)", data.Content)
	if err != nil {
		return 0, fmt.Errorf("failed to execute insert: %v", err)
	}

	lastInsertID, err := result.LastInsertId()
	if err != nil {
		return 0, fmt.Errorf("failed to retrieve last insert ID: %v", err)
	}

	return lastInsertID, nil
}

/*
指定したIDのデータをDBにINSERTする
*/
func InsertById(id int, data models.RegisterRequest) error {
	db := CreateDBConnection(envVar)
	defer db.Close()

-	_, err := db.Exec("INSERT INTO todos (ID, Content, Until) VALUES (?, ?, ?)", id, data.Content, data.Until)
+	_, err := db.Exec("INSERT INTO todos (ID, Content) VALUES (?, ?)", id, data.Content)
	if err != nil {
		return fmt.Errorf("failed to execute insert: %v", err)
	}

	return nil
}

main_test.goも同様に修正が必要。
すべて示すと長くなるので、該当箇所のみ示す。

/*
updateエンドポイントのテストコード
*/
func TestUpdateHandler(t *testing.T) {
	// deleteテスト用のデータを作成
-	untilTime := "2024-12-31"
-	untilDate, err := time.Parse("2006-01-02", untilTime)
-	errorpkg.CheckError(err)

	testData := models.RegisterRequest{
		Content: "todo test content",
-		Until:   untilDate,
	}

// 省略

/*
Deleteエンドポイントのテストコード
*/
func TestDeleteHandler(t *testing.T) {
	// deleteテスト用のデータを作成
-	untilTime := "2024-12-31"
-	untilDate, err := time.Parse("2006-01-02", untilTime)
-	errorpkg.CheckError(err)


	testData := models.RegisterRequest{
		Content: "todo test content",
-		Until:   untilDate,
	}

// 省略

/*
登録エンドポイントのテスト
*/
func TestRegisterHandler(t *testing.T) {
	// リクエスト用のJSONデータの作成
-	untilTime := "2024-12-31"
-	untilDate, err := time.Parse("2006-01-02", untilTime)
-	errorpkg.CheckError(err)

	reqBody := models.RegisterRequest{
		Content: "todo test content",
-		Until:   untilDate,
	}

コードを変更したので、リグレッションテスト。
テストに書いたコードを実行することで、動きを担保する。

赤枠に示したいずれかのボタンを押すと、ファイル単位でテストできる。

実行したが、何かエラーが出る。

2024/10/22 20:49:20 invalid DSN: missing the slash separating the database name
FAIL    todoApp 0.389s
FAIL

いろいろ調べた結果、どうやら下に示しているテストコードと、.envに定義しているDATABASEの環境変数が喧嘩しているらしい。


func TestLoadEnv(t *testing.T) {
	os.Setenv("DATABASE", "test-dsn")
	dsn := env.LoadEnv("DATABASE")
	assert.Equal(t, "test-dsn", dsn, "環境変数の値が正しく読み込まれていません")
}

なので、テストコードの方の環境変数をテスト用に変更する。

func TestLoadEnv(t *testing.T) {
	os.Setenv("DATABASE_TEST", "test-dsn")
	dsn := env.LoadEnv("DATABASE_TEST")
	assert.Equal(t, "test-dsn", dsn, "環境変数の値が正しく読み込まれていません")
}

無事テストも成功した。

ok  	todoApp	0.396s

SwaggerUIへの反映

SwaggerUIへの反映も忘れずに行う。

swag init

kip2kip2

POSTを実装する2

長くなってきたので、コメントを分ける。

inputから入力したタスクを登録する処理を書いていく。

まず、JavaScriptから見つけることができるように、input要素とbutton要素にidを追加する。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Todo App</title>
    <script src="script.js" defer></script>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <h1>Welcome to Todo App!</h1>
    <div>
        <input id="createInput" placeholder="Input new todo"/>
        <button id="createButton">送信</button>
    </div>
    <div id="todoList">
    </div>
</body>
</html>

一旦、簡単なコードを書いて、buttonに要素が追加できるかを確認する

document.getElementById("createButton").addEventListener("click", function() {
    alert("Button clicked!");
});

イベントリスナーという機能を使っているので、参考

https://qiita.com/mzmz__02/items/873118fbd8723c44956d

kip2kip2

POSTを実装する3

作業の関係からコメントをさらに分割

機能を実装していく

さて、これでボタンに関数を結びつけることができたので、実際に使用する関数を書いていく。

Todoは以下になる。(順番は思いつき順なので、実装順ではない。要するにテキトー)

  • APIにPOSTをフェッチするコードを書く
  • フェッチが成功したら、POSTしたTodoを画面に追加する(末尾に追加する)
  • POSTが成功した場合のAPIを変更する(追加のために、Todoのデータを返すようにした方が良い)
  • テストコードの変更(API側の処理を新規登録したTodoを返すように変更するので)

テストコードの変更

まず、POSTしたあとに、画面に新規登録されたTodoを追加しなければならない。
現在は画像のように、stringが入ったresultだけが返ってくる形式になっている。

レスポンスではTodo型として登録されたTodoを返し、そのデータを用いて画面に反映するという流れでやりたい。

要するにこうしたい。

なので、APIコードの変更が必要となる。

というわけで、テストコードから変えていこう。

main_test.go

/*
登録エンドポイントのテスト
*/
func TestRegisterHandler(t *testing.T) {
	// リクエスト用のJSONデータの作成
	reqBody := models.RegisterRequest{
		Content: "todo test content",
	}

	jsonData, err := json.Marshal(reqBody)
	if err != nil {
		t.Fatalf("Failed to marshal request: %v", err)
	}

	// JSONリクエストの作成
	req, err := http.NewRequest("POST", "/api/register", bytes.NewBuffer(jsonData))
	if err != nil {
		t.Fatalf("Failed to create request: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")

	// レスポンス記録のためのレコーダーを用意
	rr := httptest.NewRecorder()

	// ハンドラーの呼び出し
	handler := http.HandlerFunc(registerHandler)
	handler.ServeHTTP(rr, req)

	// ステータスコードが200かの確認
	if status := rr.Code; status != http.StatusOK {
		t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK)
	}

	// レスポンスの内容を確認
-	var resBody models.Response
+	var resBody models.Todo
	if err := json.NewDecoder(rr.Body).Decode(&resBody); err != nil {
		t.Fatalf("Failed to decode response: %v", err)
	}

-	expectedMessage := "SUCCESS"
+	expectedMessage := "todo test content"
	if resBody.Result != expectedMessage {
-		t.Errorf("Handler returned unexpected body: got %v want %v", resBody.Result, expectedMessage)
+		t.Errorf("Handler returned unexpected body: got %v want %v", resBody.Content, expectedMessage)
	}
}

さて、テストを実行して、レッドバーにしておく。

APIの修正

登録したTodoをレスポンスできるようにAPI側のコードを修正する。

SwaggerUI用のコメントも修正しておく。

main.go

// registerhandler godoc
// @Summary TodoをDBに登録する
// @Description リクエストに含まれるTodoのデータをDBに登録する
// @Tags todos
// @Accept json
// @Produce json
// @Param registerRequest body models.RegisterRequest true "Register Todo"
// @Success 200 {object} models.Todo
// @Failure 400 {object} models.Todo
// @Failure 500 {object} models.Todo
// @Router /api/register [post]
/*
リクエストに含まれるデータをDBに登録するハンドラ
*/
func registerHandler(w http.ResponseWriter, r *http.Request) {
	var req models.RegisterRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	// DBへの登録処理を行う
	insertId, err := db.Insert(req)
	// DB登録処理が失敗なら、エラーメッセージを格納したResponseデータに変更
	if err != nil {
		errResponse := models.Response{
			Result: "Data register error.",
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(errResponse)
		return
	}

	// 登録成功時のレスポンス
	response := db.SelectById(int(insertId))
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

さて、これでテストコードを動かして、ちゃんと動くことを確認できたらOK。

kip2kip2

POSTを実装する4

これで、APIとしてTodoを返すようになったので、あとはフロント側でフェッチする処理を書くだけである。

布石としておいていた以下のコードを改造して機能を追加していく。

document.getElementById("createButton").addEventListener("click", function() {
    // ここにフェッチ処理を書く
    alert("Button clicked!");
});

仮実装

フェッチ動作のみを仮実装する。

const apiRegisterEndpoint = "http://localhost:8080/api/register"

document.getElementById("createButton").addEventListener("click", function() {
    // inputの値を取得
    const inputText = document.getElementById("createInput").value

    // 入力が空の場合は何もしない
    if (!inputText) {
        alert("入力が空です")
        return
    }

    // リクエストする対象のテキストをJSONに詰める
    const requestData = {
        Content: inputText
    }

    fetch(apiRegisterEndpoint, {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify(requestData)
    })
    .then((response) => {
        // responseがエラーの場合
        if (!response.ok) {
            throw new Error("Failed to register Todo item")
        }
        return response.json()
    })
    .then(responseJson => {
        // responseで返ってきたJSONの内容により処理を分岐
        // 成功の場合
        if (!responseJson.result) {
            // todo:ここを変更する
            alert("登録成功!")
        // 失敗の場合
        } else {
            alert(`Error: ${responseJson.result}`)
        }
    })
    // フェッチそのもののエラーをキャッチする
    .catch(error => {
        console.error("There was a problem with the fetch operation:", error)
    })
})

コメントにtodo:と書いてあるところに、登録したTodoを、画面の末尾に追加する処理を書く想定。
一旦、仮実装でフェッチ動作のみ追加している。

動作の確認もOK。

kip2kip2

POSTのレスポンスのフロントへの反映

POSTの登録結果をフロント側に反映する。

document.getElementById("createButton").addEventListener("click", function() {
    // inputの値を取得
    const inputText = document.getElementById("createInput").value

    // 入力が空の場合は何もしない
    if (!inputText) {
        alert("入力が空です")
        return
    }

    // リクエストする対象のテキストをJSONに詰める
    const requestData = {
        Content: inputText
    }

    fetch(apiRegisterEndpoint, {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify(requestData)
    })
    .then((response) => {
        // responseがエラーの場合
        if (!response.ok) {
            throw new Error("Failed to register Todo item")
        }
        return response.json()
    })
    .then(responseJson => {
        // responseで返ってきたJSONの内容により処理を分岐
        // 成功の場合
        if (!responseJson.result) {
            displayTodo(responseJson)
            document.getElementById("createInput").value = ""
        // 失敗の場合
        } else {
            alert(`Error: ${responseJson.result}`)
        }
    })
    // フェッチそのもののエラーをキャッチする
    .catch(error => {
        console.error("There was a problem with the fetch operation:", error)
    })
})

すでに作っていたdisplayTodoにわたすだけで追加できる。

さて、動作確認。

OK。

kip2kip2

実装の抜けを修正

実装に抜けがあったのが発覚。

具体的には、PUTでDoneが更新されたときにフロントに反映するのを忘れていた。

取り消し線をトグルする処理を追加した。

function toggleUpdateButton(id) {
    const todoItem = document.querySelector(`[data-id='${id}']`)
    if (todoItem) {
        const doneButton = todoItem.querySelector("button")
        const contentElement = todoItem.querySelector("p")

        if (doneButton) {
            if(doneButton.textContent === taskDoneButtonText) {
                doneButton.textContent = taskNotDoneButtonText
                contentElement.style.textDecoration = "none"
            } else if (doneButton.textContent === taskNotDoneButtonText) {
                doneButton.textContent = taskDoneButtonText
                contentElement.style.textDecoration = "line-through"
            }
        }
    } 
}

取り消し線が確認できる。

このスクラップは1ヶ月前にクローズされました