こんにちは。
今回は最近流行りのGoの勉強ついでにQiitaを書いてみようと思います。
普段はReactでのフロント開発やRailsでAPI開発などをしています。
社内でもRailsからGoへの書き換えが始まっている中で、Goってどうやって書くの?インフラわからないんだけどデプロイしてみたい!って軽い気持ちでサンプルアプリを作ってみたいと思います。
意外とローカルで動くものを作って終わりな記事が多いので今回はデプロイまで行うことをゴールとして開発していきます!!
使用技術
- go
- gin
- go modules
- mysql
- docker
- gorm
- AWS
参考文献
Go / Gin で超簡単なWebアプリを作る
DockerでGoの開発環境を構築する
早速やっていこーう(環境構築)
(ここではgoのインストールは省略しまーす。)
まずやることはディレクトリの作成!
mkdir perfect_todo
cd perfect_todo
そして今回はgoのライブラリーを管理するためにgo modというものを使っていきます。これはどのライブラリーを入れたかを管理してくれるので、docker化した時や共同開発の時にとても便利らしい。。。
go mod init perfect_todo
これで go.mod
と go.sum
というファイルが生成されたかと思います。
そしたら次にgoのフレームワークであるginをインストールしていきたいと思います。
go get github.com/gin-gonic/gin
go.mod
をみてみてください。これで github.com/gin-gonic/gin
が追加されていたらインストールが完了しています!
それが成功したら
touch main.go
でmain.goのファイルを作ります。このファイルが実行されて、ここでインポートされているファイルが読み込まれていくので、アプリケーションの肝となるファイルです。
package main
import (
"github.cogom/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.LoadHTMLGlob("templates/*.html")
router.GET("/", func(c *gin.Context){
c.HTML(200, "index.html", gin.H{})
})
router.Run()
}
ここではginを使って、ルーティングを設定しています。/にアクセスが来たらindex.htmlを表示するといった具合に設定をしています。
そしてそのindex.htmlがなくては表示させるファイルがないのでindex.htmlを作成します!
templatesディレクトリーを作ってその下にindex.htmlを作成します
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Sample App</title>
</head>
<body>
<h1>Hello World!!!</h1>
</body>
</html>
これでお待ちかねのHello Worldの準備が完了です!
go run main.go
これを実行して localhost:8080
にいくと。。。!?!?
ででーーーーーん!!
Hell World!
何回みてもこの感動は良いですよねえ。
Docker化
ここではDockerの説明は省きますが、Dockerにしておくと環境の違いで苦しんだりすることがないのでDocker化しておきます。
touch Dockerfile
で Dockerfile
を作成します。
FROM golang:latest
RUN mkdir /go/src/app
WORKDIR /go/src/app
ADD . /go/src/app
VOLUME /go/src/app
RUN go mod download
EXPOSE 8080
CMD "go" "run" "main.go"
途中の go mod download
でさっき作成した go.mod
から自動でライブラリーを読み込んでくれると!便利ですね。
あとは今までやってきたことをDockerにやってもらっているだけです!
では実行していきましょう!
docker build -t perfect_todo .
docker run -it -p 8080:8080 perfect_todo
これでさっき実行した時と同じ挙動になったら成功です!!
Todoの作成
ここまで実際にGinやdockerを使って、動作するところまでは確認できたのでTodoアプリの作成に入っていきます。
まずはTodoというモデルを作っていきましょう。
ここではTodoには「やること(text)」「現在の状況(status)」「期限(deadline)」があると考え、以下のように作りました。
package domain
import (
"github.com/jinzhu/gorm"
)
type Todo struct {
gorm.Model
Text string
Status Status
Deadline int
}
type Status int
const (
Task Status = iota
ThisWeek
Doing
Review
Done
Close
)
Statusはenumで定義しています。
次にDBとのデータのやりとりを定義します。読み取りや書き込みです。一般的なCRUDを定義したいと思います。
package infrastructure
import (
"fmt"
"perfect_todo/domain"
"github.com/jinzhu/gorm"
)
func DbInit() *gorm.DB {
db, err := gorm.Open("mysql", "root:@tcp(localhost:3306)/perfect_todo?parseTime=true")
if err != nil {
fmt.Errorf("could not open database")
}
db.AutoMigrate(&domain.Todo{})
return db
}
func DbCreate(todo domain.Todo) {
db, err := gorm.Open("mysql", "root:@tcp(localhost:3306)/perfect_todo?parseTime=true")
if err != nil {
fmt.Errorf("could not open database")
}
db.Create(&todo)
}
func DbRead(id ...int) []domain.Todo {
db, err := gorm.Open("mysql", "root:@tcp(localhost:3306)/perfect_todo?parseTime=true")
if err != nil {
fmt.Errorf("could not open database")
}
var todos []domain.Todo
db.Find(&todos)
return todos
}
func DbUpdate(id int, text string, status domain.Status, deadline int) domain.Todo {
db, err := gorm.Open("mysql", "root:@tcp(localhost:3306)/perfect_todo?parseTime=true")
if err != nil {
fmt.Errorf("could not open database")
}
var todo domain.Todo
db.First(&todo, id)
todo.Text = text
todo.Status = status
todo.Deadline = deadline
db.Save(&todo)
return todo
}
func DbDelete(id int) {
db, err := gorm.Open("mysql", "root:@tcp(localhost:3306)/perfect_todo?parseTime=true")
if err != nil {
fmt.Errorf("could not open database")
}
var todo domain.Todo
db.First(&todo, id)
db.Delete(&todo)
}
一番上のDbinitでTodoのモデルをマイグレートし、他の関数を使ってデータの書き込みや読み取りなどを行います。
ここでdirectory構造が気になるかたがいるかもしれませんが、ここでは少しclean architectureを意識して作っているため、役割を分けてdirectoryを作成しています。興味のある方は調べてみてください。僕もまだ薄い知識なので直した方が良いところなど教えていただけると幸いです。
最後にtodoをPOSTするためのroutingを設定します。
db := infrastructure.DbInit()
defer db.Close()
router.GET("/", func(c *gin.Context) {
todos := infrastructure.DbRead()
c.HTML(200, "index.html", gin.H{
"todos": todos,
})
})
router.POST("/new", func(c *gin.Context) {
text := c.PostForm("text")
rawStatus := c.PostForm("status")
id, err := strconv.Atoi(rawStatus)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
status := domain.Status(id)
rawTime := c.PostForm("deadline")
deadline, err := strconv.Atoi(rawTime)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
todo := domain.Todo{Text: text, Status: status, Deadline: deadline}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
infrastructure.DbCreate(todo)
c.Redirect(302, "/")
})
ここは参考にした記事を見よう見まねで作っています。
ところどころ受け取った情報のキャストを行ったり、それに失敗した場合にはエラーを返すように設定しています。
そしてhtmlの方も変更を行います
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Sample App</title>
</head>
<body>
<h2>追加</h2>
<form method="post" action="/new">
<p>内容<input type="text" name="text" size="30" placeholder="入力してください" ></p>
<p>状態
<select name="status">
<option value="0">Task</option>
<option value="1">ThisWeek</option>
<option value="2">Doing</option>
<option value="3">Review</option>
<option value="4">Done</option>
<option value="5">Close</option>
</select>
</p>
<p>期限
<input type="text" name="deadline">
</p>
<p><input type="submit" value="Send"></p>
</form>
<ul>
{{ range .todos }}
<li>内容:{{ .Text }}、状態:{{ .Status }}、期限:{{ .Deadline }}</li>
{{end}}
</ul>
</body>
</html>
これでTodoを登録して表示する最低限の機能は整ったはずです!
docker-composeの導入
次にDBの方のDockerfileを作成します。
FROM mysql:5.7
RUN touch /var/log/mysql/mysqld.log
そしてアプリのコンテナとDBコンテナを繋げるためにdocker-composeを使っていきます。
version: '3'
services:
api:
build:
context: .
dockerfile: ./Dockerfile
ports:
- "8080:8080"
volumes:
- .:/go/src/app
links:
- db
depends_on:
- db
db:
build:
context: .
dockerfile: ./mysql/Dockerfile
container_name: todo_db
environment:
MYSQL_USER: root
MYSQL_ROOT_PASSWORD: hoge
MYSQL_DATABASE: go_todo
hostname: todo_db
command: mysqld --character-set-server=utf8 --collation-server=utf8_unicode_ci
ports:
- "3306:3306"
volumes:
- todo_db_data:/var/log/mysql
volumes:
todo_db_data:
driver: local
一番下のvolumesはDBのデータを永続化を行っています。これを行わないとコンテナを削除した際にデータが消えてしまいます。
他にもDBの文字設定もcommandで行っています。
そしてここで環境変数を使えるように.envを導入していきます。
go get github.com/joho/godotenv
新しく.envファイルを作成して、
MYSQL_USER=root
MYSQL_PASSWORD=hoge
MYSQL_DATABASE=go_todo
MYSQL_HOST=todo_db
そしてここで環境変数をgithubにpushしないように.gitignoreにも.envを追加しておきましょう。
.gitignoreを作ってなかった方はここで作ります。
.env
環境変数を使うことにともなって、DB接続のソースコードも変えていきます。
...
func Env_load() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
}
func main() {
Env_load()
router := gin.Default()
router.Use(middleware.RequestLogger())
router.LoadHTMLGlob("templates/*.html")
...
package infrastructure
import (
"database/sql"
"fmt"
"go_todo/domain"
"os"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func connectDB() *sql.DB {
user := os.Getenv("MYSQL_USER")
password := os.Getenv("MYSQL_PASSWORD")
database := os.Getenv("MYSQL_DATABASE")
host := os.Getenv("MYSQL_HOST")
sqlDB, err := sql.Open("mysql", user+":"+password+"@tcp("+host+")/"+database+"?parseTime=true&loc=Local")
if err != nil {
fmt.Errorf("could not open database")
}
return sqlDB
}
func DBInit() *gorm.DB {
sqlDB := connectDB()
db, err := gorm.Open(mysql.New(mysql.Config{
Conn: sqlDB,
}), &gorm.Config{})
if err != nil {
fmt.Errorf("could not open database")
}
db.AutoMigrate(&domain.Todo{})
return db
}
func DBCreate(todo domain.Todo) {
sqlDB := connectDB()
db, err := gorm.Open(mysql.New(mysql.Config{
Conn: sqlDB,
}), &gorm.Config{})
if err != nil {
fmt.Errorf("could not open database")
}
db.Create(&todo)
}
func DBRead(id ...int) []domain.Todo {
sqlDB := connectDB()
db, err := gorm.Open(mysql.New(mysql.Config{
Conn: sqlDB,
}), &gorm.Config{})
if err != nil {
fmt.Errorf("could not open database")
}
var todos []domain.Todo
db.Find(&todos)
return todos
}
func DBUpdate(id int, text string, status domain.Status, estimate int, time int) domain.Todo {
sqlDB := connectDB()
db, err := gorm.Open(mysql.New(mysql.Config{
Conn: sqlDB,
}), &gorm.Config{})
if err != nil {
fmt.Errorf("could not open database")
}
var todo domain.Todo
db.First(&todo, id)
todo.Text = text
todo.Status = status
todo.Estimate = estimate
todo.Time = time
db.Save(&todo)
return todo
}
func DBDelete(id int) {
sqlDB := connectDB()
db, err := gorm.Open(mysql.New(mysql.Config{
Conn: sqlDB,
}), &gorm.Config{})
if err != nil {
fmt.Errorf("could not open database")
}
var todo domain.Todo
db.First(&todo, id)
db.Delete(&todo)
}
そして実行すると!!念願のdocker環境でtodoが取得、作成できる形に!!
おまけ
途中でうまく動かなくなり、送られてきたリクエストのパラメーターをみたくなったので、middlewareを追加し、ログを吐くようにしました。
package middleware
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"github.com/gin-gonic/gin"
)
func RequestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
buf, _ := ioutil.ReadAll(c.Request.Body)
rdr1 := ioutil.NopCloser(bytes.NewBuffer(buf))
rdr2 := ioutil.NopCloser(bytes.NewBuffer(buf))
fmt.Println(readBody(rdr1))
c.Request.Body = rdr2
c.Next()
}
}
func readBody(reader io.Reader) string {
buf := new(bytes.Buffer)
buf.ReadFrom(reader)
s := buf.String()
return s
}
router := gin.Default()
router.Use(middleware.RequestLogger())
router.LoadHTMLGlob("templates/*.html")
middlewareを追加したのと、それをmain.goで読み込みます。
これで以下の画像のようにリクエストのボディーが出力されます。
出力をもっと綺麗にしたり、見やすくしたりはあとで行っていこうかと思います。
また、一旦休憩。。。