GoでWebアプリを作ろう 第一回 : Goで簡単なCRUD
こんにちは、Andyです。
普段はフロントエンドチームでJSばかり書いているのですが、せっかくGoの会社に入ったので良い機会だと思いGoに入門してみました。「Goの作法」を知ればより裏側のシステムについての理解が深まり、フロント側も良いプロダクトが作れるんじゃないかなと期待しています。
せっかく新しい言語を学ぶので、学習の中でやった事や詰まった事を文字で残そうというのが本記事の目的。
とてもじゃないですが1回で全てをカバーできないので数回に分けてチャレンジします。
手探りで自分なりのベストプラクティスを模索している最中なのでマサカリ大歓迎です。
現在のスタック
学習を始めるにあたって、自分のエンジニアとしてのスタックはこんな感じ。
- Ruby on Rails, ES6 (業務レベル)
- PHP, Perl, Python (趣味レベル)
ちなみに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を参考のこと
パッケージ管理
依存関係の管理には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
でサーバーを起動
ページが表示されました👏👏
Databaseの用意
DBはMySQLを利用していきます。
$ mysql -u root mysql> CREATE DATABASE gwa;
なにかと衝突しそうな名前だけどとりあえずDBを作成。
$ 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
を実行してマイグレーションを実行し、テーブルが作成されていることを確認します
ORM(?)の選定
MySQLとの接続をラップしてくれるORMとしてGORMやsqlxなどを検討したものの、
「いちいち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") }
サーバーを再起動してエンドポイントが有効なことを確認。当然結果は空っぽです。
雑にレコードを突っ込んでレスポンスを再確認
良さそう
タスクを追加する
そういえば自動採番の設定を忘れていたので新しい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) ...
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) ...
更新成功!
タスクの削除
同じ要領でコントローラーに追記
// 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) ...
削除も問題なくできました。
まとめ
複雑な機能は何もありませんがとりあえずのAPIサーバーができました。Go楽しいですね。
第二回以降はログイン機能とかモデルの関連付けとか作業の自動化とかやりたいです。
それではまた次回
*1:text/templateとか使ってる人類いるんですか?