Go言語によるTodoアプリの作成ログ
Go言語でTodoアプリを作成するので、ログを残す試み
以下の書籍を参考とする予定
経緯としては
・人に教える必要が出たため、Todoアプリを題材にしようと思った
・ついでに自分もGoに入門したい
・mattnさんの以下の記事に触発されて、ベースとなるTodoアプリを作りたかった
といったところ
なお、人に教えるときに共有するログになるため、記述がかなり冗長になると思われる
参考書籍に従って、以下のものを使用してみる予定。
難しすぎたり、要件に合わない場合はそのときにまた検討しようと思う。
フレームワーク:labstack/echo
テンプレートエンジン:html/template ORM:uptrace/bunまずDB周りのコードを学んで書いていこうと思っていたが、Rustで慣れ親しんだsqlxがgoに見つかったので、こっちを使うように方針変更
見切り発車なので、今後もこうやって方針をどしどし変えていくと思われる
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からの読み込み用に以下も追加
.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と書いてたけど、ハイライトしなかったので...)
そういえばgoの命名規則を意識できてなかったのでこちらを参考にしよう
python、Rustとやってきたので、意識しないとスネークケースで書いてしまう...
リファクタリング
リファクタリングして関数に切り出し
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!
先にDBの準備がいるのを忘れていた
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>
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;
テーブル接続の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
リファクタリング
ここまでのコードのなかで、リファクタリングしたいところを挙げる
- DBコネクション取得は外部関数に切り出したい
- クエリの実行を外部関数に切り出したい
上は今やってもいいけど、下のは一旦保留かな~
あくまでテスト用のDB接続なので、切り出すには早い気がする
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
今後、関数への切り出しが増えていくと何をする関数かぱっと見わからなくなるので、後のことを考えてドキュメントを追加
godocに使える形で記載するのを目標にしたい
以下は参考記事
// 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
}
コメントの形式間違えたので修正
/*
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
}
リポジトリ切ってなかったのを思い出したのでリポジトリを切ることにする
コミットは途中からになるけど、練習用に作ってるんだしまあいいかの精神
ちょっとリファクタリング
環境変数指定のハードコーディングをやめて、引数で受け取れるように変更
/*
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)
ところで変数の名前、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
}
省略するが、呼び出し側も同様に変更している
テストコードの作成
関数切り出しも行ったので、テストコードを書いて、今後、コードの変更をする場合の動作を担保したいと思う
とりあえず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コネクションが作成されていません")
}
これで、テスト対象の関数に変更があった場合に、ちゃんと動作するかを確かめることができる
とはいえ、テストケースを網羅したわけではないので、限定的にはなるけども
テスト用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との接続テストがいつでも行えるようになった
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)
}
エラーハンドリングについて
毎回以下のコードを書くのが面倒なのでマクロ化したい
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)
}
}
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)
}
sqlxの参考資料
ぼちぼち関数が散らかってきたので、整理しよう
フォルダ整理
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)
}
sqlxの動作確認はこれでいいと思うので、実際にTodoアプリで使うDB設計をしようと思う
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)
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の扱いが微妙な感じがする...
あとあと、ここは変更するかも
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
}
接続テスト用の関数はいらないため、そのうち削除予定
今のところはそのままおいている
複数データの取得
目的は全てのデータの取得のため、リストで受け取る必要がある
ダミーデータの追加
複数件受け取る処理をテストするためのダミーデータを追加する
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>}
Goのディレクトリ構成
Goのディレクトリ構成について学んでみる
公式のディレクトリ構成のドキュメント
作りながらディレクトリ分割する方法について
ディレクトリ分割の参考
選択
公式のディレクトリ構成が良さそうなので、それを採用してみようかなと思う
- cmdにエントリーポイント(プログラムが実行を開始する場所、プログラムの起動時に最初に実行される関数やコード)を配置
- internalに他のもの(DB接続コードや、envの読み込み等のユーティリティ関数)を配置
といったようなざっくり理解で見切り発車する
ディレクトリ構成の見直し
まず現在の構成がどうなっているか
$ 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
データを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
}
]
書きはしたけども
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でレスポンスするコードのシンプルさに驚いた
こんな簡単に実現できるようになってるんだね〜
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"`
}
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
ハンドラー関数を書く
テストコードで書いた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)
}
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
となっている理由
構造体を別のファイルに切り出す
構造体が増えてきたため、個別のファイルに切り出して管理する
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
}
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
}
コメントの追加
だんだん複雑になってきたので、コメントを追加(ほぼ自分用)
コメントを追加する効用として自分が感じているものは
- あとで見返した時、関数の細かい動作を忘れていることがあるので(というよりオールウェイズ)、コメントがあったほうが変更するときにスムーズになる
- 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
}
}
登録データの削除
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へ接続して、ちゃんと削除されたかを確認する。
確認方法は他の箇所で記載しているため、ここでは省略する。
リファクタリング
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)
}
}
テストコードが改修できたので、グリーンバーになることを確認する。
リファクリング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
}
APIの完成
これでAPIのロジックは完成した。
次はフロント部分を作り込んでいく。
フロントの仕様
フロントの仕様をどうするか。
バックエンドをAPIとして完全に独立して実装したため、フロントはAPIをフェッチする形で実装する必要がある。
とりあえず方針としては以下のようにしたい。
- スタティックなhtmlを返すエンドポイントを作成し、画面を返す。
- 画面からJavaScriptを用いて、データをフェッチしてくる。
ファイルの準備
まず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
フロントの準備をする
まず、動作確認のため、簡単なテスト用のコードを記載し、動作を確認する。
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
と入力し、アクセスする。
次のような画面が表示されたら成功。
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がなにかといったところは下記を参照してほしい。
DOMに関しては調べるといくらでも良質な記事や動画が出てくるので、わかりやすいものを探してそちらを見たほうが理解は早いかも。
フロントに必要な要素を配置する
フロントで使う画面の要素を配置していく。
動作の実装等は後ほど行う。
- 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の処理で設置していくため、画面の要素配置はここまでで終了となる。
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
リファクタリング
テストコードで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のハードコーディングしているので、こちらも変更した。
コードなどは煩雑になるので、省略する。
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)
}
フロント処理を追加する
フロントの処理を追加していく。
APIとしてエンドポイントを作成してDBとの処理は実装されているので、あとはフロントからフェッチする処理が必要。
なお、fetchするのにPromiseという概念が使われるので、参考文献をぺたり。
参考文献
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
にアクセスする。
次に、開発者ツールを開いて、コンソールタブを開く。
参考記事
アクセスしてコンソールタブを見ると、データが取得できているのが確認できる。
以下の画像は例。
取得したデータをフロントに反映する
データが取得できるのは確認できたので、それをフロントに反映していく。
一覧で表示できるようにする
とりあえず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)
}
要素を追加していく
表示できるのは分かったので、ここから更に要素を追加していく。
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)
}
三項演算子という記法を使っている。
参考文献
デザインを整える
ボタンが説明文のしたに来ており、非常に見づらい。
なので、ちょっとだけデザインを整える。
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;
}
動作確認
あとはブラウザをリロードすれば画像の様になるはずである。
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;
}
こんな感じでどうだろう。
いい感じ。
リファクタリング
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にボタンの動きを記載するため、切り出していたほうが変更しやすい。
という感じ。
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)
})
}
APIのドキュメント化
フロント開発を進める前に、APIのドキュメント化を行う。
今の段階では、フロントを開発するときにバックエンドのソースを読みながら行っているので、非常に非効率。
というかめんどくさい。
ツールの選定
SwaggerUIを使いたいと思う。
go用のライブラリがあったので、それを使用する。
リポジトリ
参考記事
インストール
まずはインストールしておく。
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
展開するとこんな感じ
ドキュメントの変更
日本語の方が読みやすいので、説明文を変更する。
// @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
残った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の仕様が確認しやすくなった。
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カラム)
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
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!");
});
イベントリスナーという機能を使っているので、参考
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。
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。
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。
実装の抜けを修正
実装に抜けがあったのが発覚。
具体的には、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"
}
}
}
}
取り消し線が確認できる。
完成
完成したので、無事クローズ。
最後にリポジトリのURLをぺたり。