Go言語でREST APIを作ってみる【Echo + GORM + MySQL】

# はじめに
普段Railsを触っていますが、Golangという響きに興味を持ち試しに触ってみました。
慣れない言語だと色々と試しながらで新鮮で楽しくキャッチアップを続けていけそうです。

今回は以前にNext.js, mongoDBを使って作成していたアプリのBE側を「GolangのAPIに置き換える」という名目で進めています。

Golangではじめて書いたコードなのでベストプラクティスではない点はご容赦ください。
これから参考GolangでAPIを書く方の力になれれば幸いです。

# 何を作るか
冒頭で述べた通り、FEをNext.jsで作成中のBE側をAPIに置き換える名目で作成しています。
このアプリは『Bike Noritai』というアプリでバイカーのツーリングスポットやツーリング記録ができるようなアプリです。

[Go言語でREST APIを作ってみる②（User周り）【Echo + GORM + MySQL】
](https://qiita.com/rorrou25/items/9ee6a0590b70db0ed0cb)

## ディレクトリ構成
将来的にはhandler, modelは増やしていく想定です。
大まかな構成はこのようにしています。
```
.
├── handler
│   ├── spot.go
│   └── user.go
├── model
│   ├── spot.go
│   └── user.go
├── repository
│   └── db.go
├── router
│   └── router.go
├── test
    ├── .env
│   └── user_test.go
├── .env
├── .gitignore
├── api.dockerfile
├── db.dockerfile
├── docker-compose.yml
├── go.mod
├── go.sum
├── main.go
├── Makefile
├── openapi.yml
└── README.md

```

## OpenAPI
仕様は以下のようなものを想定しています。
FE側で必要なデータはすでに決まっているのでそちらに合わせたレスポンスを返すようにしています。
```openapi.yml
openapi: "3.0.2"
info:
  title: Bike Noritai API
  description: |
    - Bike Noritai のAPI
    - [bike-noritaiフロント](https://github.com/tatsuro1997/bike-noritai)
  version: "1.0"
servers:
  - url: http://localhost:8080
paths:
  "/api/users":
    get:
      description: ユーザー一覧取得
      operationId: UserIndex
      responses:
        "200":
          description: ユーザー一覧
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
    post:
      description: ユーザー登録
      operationId: UserCreate
      requestBody:
        $ref: "#/components/requestBodies/User"
      responses:
        "201":
          description: Created

  "/api/users/{user_id}":
    get:
      description: ユーザー詳細
      operationId: UserShow
      parameters:
        [$ref: "#/components/parameters/UserId"]
      responses:
        "200":
          description: ユーザーデータ
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
    patch:
      description: ユーザー更新
      operationId: UserUpdate
      parameters:
        [$ref: "#/components/parameters/UserId"]
      requestBody:
        $ref: "#/components/requestBodies/User"
      responses:
        "204":
          description: No Content
    delete:
      description: ユーザー削除
      operationId: UserDelete
      parameters:
        [$ref: "#/components/parameters/UserId"]
      responses:
        "204":
          description: No Content

  "/api/spots":
    get:
      description: スポット一覧取得
      operationId: SpotIndex
      responses:
        "200":
          description: スポットデータ
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Spot"
    post:
      description: スポット登録
      operationId: SpotCreate
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Spot"
      responses:
        "201":
          description: Created

  "/api/spots/{spot_id}":
    get:
      description: スポット詳細取得
      operationId: SpotShow
      parameters:
        [$ref: "#/components/parameters/SpotId"]
      responses:
        "200":
          description: スポットデータ
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Spot"

  "/api/users/{user_id}/spots":
    get:
      description:  ユーザー毎スポット一覧取得
      operationId: User/SpotIndex
      parameters:
        [
          $ref: "#/components/parameters/UserId",
        ]
      responses:
        "200":
          description: スポットデータ
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Spot"

  "/api/users/{user_id}/spots/{spot_id}":
    patch:
      description: スポット更新
      operationId: SpotUpdate
      parameters:
        [
          $ref: "#/components/parameters/UserId",
          $ref: "#/components/parameters/SpotId"
        ]
      requestBody:
        $ref: "#/components/requestBodies/Spot"
      responses:
        "204":
          description: No Content
    delete:
      description: スポット削除
      operationId: SpotDelete
      parameters:
        [
          $ref: "#/components/parameters/UserId",
          $ref: "#/components/parameters/SpotId"
        ]
      responses:
        "204":
          description: No Content

  "/api/users/{user_id}/records":
    get:
      description: ツーリング記録一覧取得
      operationId: User/RecordIndex
      parameters:
        [
          $ref: "#/components/parameters/UserId",
        ]
      responses:
        "200":
          description: ツーリング記録データ
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Record"
    post:
      description: ツーリング記録登録
      operationId: RecordCreate
      parameters:
        [
          $ref: "#/components/parameters/UserId",
        ]
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Spot"
      responses:
        "201":
          description: Created

  "/api/users/{user_id}/records/{record_id}":
    get:
      description: ツーリング記録詳細
      operationId: RecordShow
      parameters:
        [
          $ref: "#/components/parameters/UserId",
          $ref: "#/components/parameters/RecordId"
        ]
      responses:
        "200":
          description: ツーリング記録データ
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Record"
    patch:
      description: ツーリング記録更新
      operationId: RecordUpdate
      parameters:
        [
          $ref: "#/components/parameters/UserId",
          $ref: "#/components/parameters/RecordId"
        ]
      requestBody:
        $ref: "#/components/requestBodies/Record"
      responses:
        "204":
          description: No Content
    delete:
      description: ツーリング記録削除
      operationId: RecordDelete
      parameters:
        [
          $ref: "#/components/parameters/UserId",
          $ref: "#/components/parameters/RecordId"
        ]
      responses:
        "204":
          description: No Content

  "/api/users/{user_id}/records/{record_id}/record_likes":
    get:
      description: ツーリングいいね数
      operationId: RecordLikeCount
      parameters:
        [
          $ref: "#/components/parameters/UserId",
          $ref: "#/components/parameters/RecordId"
        ]
      responses:
        "200":
          description: ツーリング記録データ数
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: integer
                    format: int64

  "/api/users/{user_id}/comments":
    get:
      description: ユーザー毎コメント一覧
      operationId: CommentIndex
      parameters:
        [
          $ref: "#/components/parameters/UserId",
        ]
      responses:
        "200":
          description: コメントデータ
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comment"

  "/api/users/{user_id}/records/{record_id}/comments":
    get:
      description: コメント一覧
      operationId: UserCommentIndex
      parameters:
        [
          $ref: "#/components/parameters/UserId",
          $ref: "#/components/parameters/RecordId"
        ]
      responses:
        "200":
          description: コメントデータ
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comment"
    post:
      description: コメント作成
      operationId: CommentCreate
      parameters:
        [
          $ref: "#/components/parameters/UserId",
          $ref: "#/components/parameters/RecordId"
        ]
      requestBody:
        $ref: "#/components/requestBodies/Comment"
      responses:
        "201":
          description: Created
    patch:
      description: コメント更新
      operationId: CommentUpdate
      parameters:
        [
          $ref: "#/components/parameters/UserId",
          $ref: "#/components/parameters/RecordId"
        ]
      requestBody:
        $ref: "#/components/requestBodies/Comment"
      responses:
        "204":
          description: No Content
    delete:
      description: コメント削除
      operationId: CommentDelete
      parameters:
        [
          $ref: "#/components/parameters/UserId",
          $ref: "#/components/parameters/RecordId"
        ]
      responses:
        "204":
          description: No Content

  "/api/users/{user_id}/spots/{spot_id}/bookmarks":
    get:
      description: スポットブックマーク
      operationId: BookmarkIndex
      parameters:
        [
          $ref: "#/components/parameters/UserId",
          $ref: "#/components/parameters/SpotId"
        ]
      responses:
        "200":
          description: ブックマークデータ
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Bookmark"

components:
  parameters:
    UserId:
      name: user_id
      in: path
      required: true
      schema:
        type: integer
      description: ユーザーID
    SpotId:
      name: spot_id
      in: path
      required: true
      schema:
        type: integer
      description: スポットID
    RecordId:
      name: record_id
      in: path
      required: true
      schema:
        type: integer
      description: レコードID

  requestBodies:
    User:
      content:
        application/json:
          schema:
            type: object
            required:
              - "name"
              - "password"
            properties:
              email:
                type: string
              password:
                type: string
              name:
                type: string
              area:
                type: string
              prefecture:
                type: string
              url:
                type: string
              bike_name:
                type: string
              experience:
                type: integer
                format: int8
    Spot:
      content:
        application/json:
          schema:
            type: object
            required:
              - "user_id"
              - "name"
            properties:
              user_id:
                type: integer
                format: int64
              name:
                type: string
              image:
                type: string
              type:
                type: string
              address:
                type: string
              hp_url:
                type: string
              open_time:
                type: string
              off_day:
                type: string
              parking:
                type: boolean
              description:
                type: string
              lat:
                type: number
                format: float
              lng:
                type: number
                format: float
    Record:
      content:
        application/json:
          schema:
            type: object
            required:
              - "user_id"
              - "spot_id"
            properties:
              user_id:
                type: integer
                format: int64
              spot_id:
                type: integer
                format: int64
              date:
                type: string
                format: date
              weather:
                type: string
              temperature:
                type: string
              running_time:
                type: string
              distance:
                type: string
              description:
                type: string
    RecordLike:
      content:
        application/json:
          schema:
            type: object
            required:
              - "user_id"
              - "spot_id"
            properties:
              user_id:
                type: integer
                format: int64
              spot_id:
                type: integer
                format: int64
    Comment:
      content:
        application/json:
          schema:
            type: object
            required:
              - "user_id"
              - "spot_id"
            properties:
              user_id:
                type: integer
                format: int64
              spot_id:
                type: integer
                format: int64
              name:
                type: string
              text:
                type: string
    Bookmark:
      content:
        application/json:
          schema:
            type: object
            required:
              - "user_id"
              - "spot_id"
            properties:
              user_id:
                type: integer
                format: int64
              spot_id:
                type: integer
                format: int64

  schemas:
    User:
      type: object
      description: ユーザー
      properties:
        id:
          type: integer
          format: int64
        email:
          type: string
        password:
          type: string
        name:
          type: string
        area:
          type: string
        prefecture:
          type: string
        url:
          type: string
        bike_name:
          type: string
        experience:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    Spot:
      type: object
      properties:
        user_id:
          type: integer
          format: int64
        name:
          type: string
        image:
          type: string
        type:
          type: string
        address:
          type: string
        hp_url:
          type: string
        open_time:
          type: string
        off_day:
          type: string
        parking:
          type: boolean
        description:
          type: string
        lat:
          type: number
          format: float
        lng:
          type: number
          format: float
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    Record:
      type: object
      properties:
        id:
          type: integer
          format: int64
        user_id:
          type: integer
          format: int64
        spot_id:
          type: integer
          format: int64
        date:
          type: string
          format: date
        weather:
          type: string
        temperature:
          type: string
        running_time:
          type: string
        distance:
          type: string
        description:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    RecordLike:
      type: object
      properties:
        id:
          type: integer
          format: int64
        user_id:
          type: integer
          format: int64
        spot_id:
          type: integer
          format: int64
        created_at:
          type: string
          format: date-time

    Comment:
      type: object
      properties:
        id:
          type: integer
          format: int64
        user_id:
          type: integer
          format: int64
        spot_id:
          type: integer
          format: int64
        name:
          type: string
        text:
          type: string
        created_at:
          type: string
          format: date-time
    Bookmark:
      type: object
      properties:
        id:
          type: integer
          format: int64
        user_id:
          type: integer
          format: int64
        bookmark_id:
          type: integer
          format: int64
        created_at:
          type: string
          format: date-time
```

# Docker
開発環境はdockerで構築しています。
```api.dockerfile
FROM golang:1.20

WORKDIR /api

COPY go.mod go.sum ./
RUN go mod download && go mod verify

CMD ["go", "run", "main.go"]
```

```db.dockerfile
FROM mysql:8.0
ENV LANG ja_JP.UTF-8
```

```docker-compose.yml
version: '3.8'

services:
  api:
    container_name: api
    build:
      context: .
      dockerfile: api.dockerfile
    tty: true
    ports:
      - 8080:8080
    depends_on:
      - db
    volumes:
      - .:/api

  db:
    container_name: db
    build:
      context: .
      dockerfile: db.dockerfile
    tty: true
    ports:
      - 3306:3306
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: "bike_noritai_dev"
      MYSQL_USER: "tester"
      MYSQL_PASSWORD: "password"
    volumes:
      - type: volume
        source: mysql_data
        target: /var/lib/mysql
    networks:
      - default

  test_db:
    container_name: test_db
    build:
      context: .
      dockerfile: db.dockerfile
    tty: true
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: test_db
      MYSQL_USER: tester
      MYSQL_PASSWORD: password
    ports:
      - "3307:3306"
    volumes:
      - type: volume
        source: test_mysql_data
        target: /var/lib/mysql

networks:
  default:
volumes:
  mysql_data:
  test_mysql_data:
```

# Makefile
開発を進めていく上であると楽なので設定しておきます。
```Makefile
build:
	docker-compose build

up:
	make down
	docker compose up api db

stop:
	docker-compose stop

down:
	docker-compose down

bash:
	docker-compose exec api /bin/bash

db:
	docker exec -it db bash

fmt:
	docker compose run --rm api gofmt -l -s -w .

go_test:
	ENV=test go test -v ./test/...

test_db:
	make down
	docker compose up test_db
```

# DBの設定
今回はMySQLを使用しています。
他のDBを使用する場合はこちらを参照。

https://gorm.io/ja_JP/docs/connecting_to_the_database.html

```db.go
package repository

import (
	"log"
	"os"

	"github.com/joho/godotenv"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

var (
	DB  *gorm.DB
	err error
)

func init() {
	err := godotenv.Load()
	if err != nil {
		log.Fatal("Error loading .env file")
	}

	dsn := os.Getenv("DEV_DB_DNS")
	if os.Getenv("ENV") == "test" {
		dsn = os.Getenv("TEST_DB_DNS")
	}

	DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		log.Fatalln(dsn + "database can't connect")
	}
```

# 環境変数を設定
```.env
DEV_DB_DNS="tester:password@tcp(db:3306)/bike_noritai_dev?charset=utf8mb4&parseTime=True&loc=Local"
```

# サーバーの設定
localhost:8080でapiのサーバーを立てるようにしておきます。
```main.go
package main

import (
	rep "bike_noritai_api/repository"
	"bike_noritai_api/router"
)

func main() {
	db, _ := rep.DB.DB()
	defer db.Close()

	e := router.NewRouter()

	e.Logger.Fatal(e.Start(":8080"))
}

```

# サーバーの疎通確認
ここまで来たらサーバーを起動させ疎通確認をします。
Echoが起動したレスポンスが返ってこればOKです。

```console
$ make build

$ make up

api  | 
api  |    ____    __
api  |   / __/___/ /  ___
api  |  / _// __/ _ \/ _ \
api  | /___/\__/_//_/\___/ v4.10.2
api  | High performance, minimalist Go web framework
api  | https://echo.labstack.com
api  | ____________________________________O/_______
api  |                                     O\
api  | ⇨ http server started on [::]:8080
```

# つづく
今回はGolang + Echo + GROMの開発環境を構築してきました。
次回は、Model、Useｒハンドラー、routerを追加していきます。

不明点や不備があれば優しく教えていただけると喜びます:relaxed:

https://qiita.com/rorrou25/items/9ee6a0590b70db0ed0cb


# 参考文献
https://echo.labstack.com/
https://gorm.io/ja_JP/
https://zenn.dev/shimpo/articles/go-echo-gorm-rest-api
https://qiita.com/kiyc/items/c20ac7bb6997c0753314