GoでWebアプリを作ろう 第一回 : Goで簡単なCRUD

こんにちは、Andyです。

普段はフロントエンドチームでJSばかり書いているのですが、せっかくGoの会社に入ったので良い機会だと思いGoに入門してみました。「Goの作法」を知ればより裏側のシステムについての理解が深まり、フロント側も良いプロダクトが作れるんじゃないかなと期待しています。

せっかく新しい言語を学ぶので、学習の中でやった事や詰まった事を文字で残そうというのが本記事の目的。

とてもじゃないですが1回で全てをカバーできないので数回に分けてチャレンジします。

手探りで自分なりのベストプラクティスを模索している最中なのでマサカリ大歓迎です。

f:id:andoshin11:20180416012128p:plain

現在のスタック

学習を始めるにあたって、自分のエンジニアとしてのスタックはこんな感じ。

ちなみにgolangの経験値はA Tour of Goを流し読みした程度です

作るもの

簡単なTODOアプリです。次回以降でユーザー認証機能も乗っけていきます。

2018年なので当然*1フロントはSPAで受ける事を考えて、JSONのI/Oを受けるAPIサーバーを想定しています。

またルーティングにGinを利用しています

プロジェクト構成

Railserとして一番始めにつまづくのがGoのプロジェクト構成です。いろいろと宗派があるようなので詳しくは触れません。

$GOPATH$HOME/dev/goに設定しています。プロジェクトは $Home/dev/go/github.com/andoshin11/go-web-appに配置しました。

アーキテクチャについてはDDDやその他のClean Architectureをベースとしたものも検討したですが、初めてのアプリなのでわかりやすくMVC(or MC?)デザインパターンを採用。

最終的なファイル構成はGitHubを参考のこと

github.com

パッケージ管理

依存関係の管理にはdepを利用

$ brew install dep
$ dep init

Hello, world!

まずは基本となるControllerを src/controller以下に作成します

// src/controller/index.go
package controller

import (
    "net/http"

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

// IndexGET displays application index page
func IndexGET(c *gin.Context) {
    c.String(http.StatusOK, "Hello, world!")
}

上記をHandlerとして登録

// main.go
package main

import (
    "github.com/andoshin11/go-web-app/src/controller"
    "github.com/gin-gonic/gin"
)

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

    router.GET("/", controller.IndexGET)
    router.Run(":8080")
}

$ go run main.goでサーバーを起動

f:id:andoshin11:20180415212530p:plain

ページが表示されました👏👏

Databaseの用意

DBはMySQLを利用していきます。

$ mysql -u root
mysql> CREATE DATABASE gwa;

なにかと衝突しそうな名前だけどとりあえずDBを作成。

マイグレーションツールはgooseを利用。

$ go get github.com/pressly/goose
$ mkdir db
$ touch db/dbconf.yml
// db/dbconf.yml
development:
    driver: mymysql
    open: tcp:localhost:3306*gwa/root/hogehoge

DBの情報が正しいか疎通確認

$ goose status
goose: status for environment 'development'
    Applied At                  Migration
    =======================================

テーブルの作成

gooseで taskテーブルを作成します

$ goose create createTask sql

上記のコマンドを実行するとタイムスタンプを名前に含んだマイグレーションファイルが db/migrations以下に作成されるので、そちらにSQLを追記

-- db/migrations/2018......createTask.sql

-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS task (
    id INT UNSIGNED NOT NULL,
    created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    title VARCHAR(255) NOT NULL,
    PRIMARY KEY(id)
);


-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE task;

$ goose upを実行してマイグレーションを実行し、テーブルが作成されていることを確認します

f:id:andoshin11:20180415233901p:plain

ORM(?)の選定

MySQLとの接続をラップしてくれるORMとしてGORMsqlxなどを検討したものの、

「いちいちstructのメンテやってられんわ」

という強い理由によりxoを採用しました。

現実問題としてファットモデルも避けたかったし、基本的なCRUDを記述することだけ考えると必要十分な気がします。

$ go get github.com/knq/xo
$ mkdir src/model
$ mkdir -p db/xo/templates
$ cp $GOPATH/src/github.com/knq/xo/templates/* db/xo/templates/
$ xo mysql://<user>:<pass>@<host>/<db> -o src/model/ --template-path db/xo/templates/

上記の手順を実行すると src/model/**.xo.goというファイルが作成されます。 goosedbversion.xo.goは必要ないので削除して大丈夫です。

Modelの接続

xoコマンドで各テーブルのmodelファイルが作成されたのでアプリケーションに接続していきます。まずは src/model/model.goを作成してDBインスタンスを返す関数を記述。

// src/model/model.go
package model

import (
    "database/sql"
    "log"
    "os"

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

// DBConnect returns *sql.DB
func DBConnect() (db *sql.DB) {
    dbDriver := "mysql"
    dbUser := "root"
    dbPass := os.Getenv("MYSQL_ROOT_PASSWORD") // 環境変数から取得
    dbName := "gwa"
    dbOption := "?parseTime=true"
    db, err := sql.Open(dbDriver, dbUser+":"+dbPass+"@/"+dbName+dbOption)
    if err != nil {
        log.Fatal(err)
    }
    return db
}

続いてTODOの一覧を返すコントローラーアクションを記述。

xoの設定をしておいてなんですが、今回だけControllerにデータ取得処理を直接書きました。本来なら templateを拡張すべきですが、実際のユースケースとして任意のテーブルのレコードを全取得するケースは中々ないので無視。

// src/controller/task.go
package controller

import (
    "database/sql"
    "fmt"
    "net/http"
    "time"

    "github.com/andoshin11/go-web-app/src/model"
    "github.com/gin-gonic/gin"
)

// TasksGET returns list of tasks
func TasksGET(c *gin.Context) {
    db := model.DBConnect()
    result, err := db.Query("SELECT * FROM task ORDER BY id DESC")
    if err != nil {
        panic(err.Error())
    }

    tasks := []model.Task{}

    // iterate result
    for result.Next() {
        task := model.Task{}
        var id uint
        var createdAt, updatedAt time.Time
        var title string

        err = result.Scan(&id, &createdAt, &updatedAt, &title)
        if err != nil {
            panic(err.Error())
        }

        task.ID = id
        task.CreatedAt = createdAt
        task.UpdatedAt = updatedAt
        task.Title = title
        tasks = append(tasks, task)
    }
    fmt.Println(tasks)
    c.JSON(http.StatusOK, gin.H{"tasks": tasks})
}

返り値はリスト形式のJSONです。 main.goで新たなnamespaceを作成して上記のHandlerを登録

// main.go
...


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

    // API namespace
    v1 := router.Group("/api/v1")
    {
        v1.GET("/tasks", controller.TasksGET)
    }

    router.GET("/", controller.IndexGET)
    router.Run(":8080")
}

サーバーを再起動してエンドポイントが有効なことを確認。当然結果は空っぽです。

f:id:andoshin11:20180415232608p:plain

雑にレコードを突っ込んでレスポンスを再確認

f:id:andoshin11:20180415234152p:plain

f:id:andoshin11:20180415234234p:plain

良さそう

タスクを追加する

そういえば自動採番の設定を忘れていたので新しいmigrationを追加

$ goose create autoIncrementTask sql
// 2018....autoIncrementTask.sql

-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
ALTER TABLE task MODIFY id INT UNSIGNED AUTO_INCREMENT NOT NULL;

-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back
ALTER TABLE task MODIFY id INT UNSIGNED NOT NULL;
$ goose up

xoのアップデートも忘れずに行います。

taskコントローラーにHandlerを追加

// src/controller/task.go

...

// TaskPOST creates a new task
func TaskPOST(c *gin.Context) {
    db := model.DBConnect()

    title := c.PostForm("title")
    now := time.Now()

    task := &model.Task{
        Title:     title,
        CreatedAt: now,
        UpdatedAt: now,
    }

    err := task.Save(db)
    if err != nil {
        panic(err.Error())
    }

    fmt.Printf("post sent. title: %s", title)
}
// main.go
...
v1.POST("/tasks", controller.TaskPOST)
...

f:id:andoshin11:20180416002605p:plain

DBにレコードが追加されるようになりました

タスクの更新

新しいHandlerを taskコントローラーに追加

// src/controller/task.go

...

// TaskPATCH updates a task
func TaskPATCH(c *gin.Context) {
    db := model.DBConnect()

    id, _ := strconv.Atoi(c.Param("id"))

    task, err := model.TaskByID(db, uint(id))
    if err != nil {
        panic(err.Error())
    }

    title := c.PostForm("title")
    now := time.Now()

    task.Title = title
    task.UpdatedAt = now

    err = task.Update(db)
    if err != nil {
        panic(err.Error())
    }

    fmt.Println(task)
    c.JSON(http.StatusOK, gin.H{"task": task})
}
// main.go

...
v1.PATCH("/tasks/:id", controller.TaskPATCH)
...

f:id:andoshin11:20180416004713p:plain

更新成功!

タスクの削除

同じ要領でコントローラーに追記

// src/controller/task.go

// TaskDELETE deletes a task
func TaskDELETE(c *gin.Context) {
    db := model.DBConnect()

    id, _ := strconv.Atoi(c.Param("id"))

    // Check if record exists
    task, err := model.TaskByID(db, uint(id))
    if err != nil {
        panic(err.Error())
    }

    err = task.Delete(db)
    if err != nil {
        panic(err.Error())
    }

    c.JSON(http.StatusOK, "deleted")
}
// main.go

...
v1.DELETE("/tasks/:id", controller.TaskDELETE)
...

f:id:andoshin11:20180416010451p:plain

削除も問題なくできました。

まとめ

複雑な機能は何もありませんがとりあえずのAPIサーバーができました。Go楽しいですね。

第二回以降はログイン機能とかモデルの関連付けとか作業の自動化とかやりたいです。

それではまた次回

*1:text/templateとか使ってる人類いるんですか?