12
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Go Gin Docker AWSで完璧なTodoアプリを作る

Last updated at Posted at 2020-07-27

こんにちは。
今回は最近流行りの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.modgo.sum というファイルが生成されたかと思います。

そしたら次にgoのフレームワークであるginをインストールしていきたいと思います。

go get github.com/gin-gonic/gin

go.mod をみてみてください。これで github.com/gin-gonic/gin が追加されていたらインストールが完了しています!

それが成功したら

touch main.go

で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を作成します

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 にいくと。。。!?!?

image.png

ででーーーーーん!!
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)」があると考え、以下のように作りました。

domain/todo.go
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を定義したいと思います。

infrastructure/db.go
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を設定します。

main.go
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の方も変更を行います

index.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を使っていきます。

docker-compose.yml
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ファイルを作成して、

.env
MYSQL_USER=root
MYSQL_PASSWORD=hoge
MYSQL_DATABASE=go_todo
MYSQL_HOST=todo_db

そしてここで環境変数をgithubにpushしないように.gitignoreにも.envを追加しておきましょう。
.gitignoreを作ってなかった方はここで作ります。

.gitignore
.env

環境変数を使うことにともなって、DB接続のソースコードも変えていきます。

main.go
...

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")
...
db.go
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を追加し、ログを吐くようにしました。

infrastructure/middleware/request_logger.go
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
}

main.go
router := gin.Default()
router.Use(middleware.RequestLogger())
router.LoadHTMLGlob("templates/*.html")

middlewareを追加したのと、それをmain.goで読み込みます。
これで以下の画像のようにリクエストのボディーが出力されます。
image.png

出力をもっと綺麗にしたり、見やすくしたりはあとで行っていこうかと思います。

また、一旦休憩。。。

12
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?