家族向け書籍管理アプリの作成②（GoでバックエンドAPI）

## はじめに

前回の記事では、家族向け書籍管理アプリのフロントエンドの環境構築と、GoogleBooksAPIを使用して書籍情報を検索する機能を実装しました。

今回は、Golangを利用してバックエンドAPIを作成します。バックエンドAPIでは、書籍の登録、検索、更新、削除などの機能を提供します。

## プロジェクトの概要

このアプリケーションは、家族全員の書籍を一元管理するためのものです。GoogleBooksAPIを使用して書籍情報を検索し、各書籍の紙媒体と電子媒体の所有状況、読了状況などを管理します。

最終的には、Flutterを使用してモバイルアプリケーションを作成し、バーコードをスキャンして書籍を登録できるようにする予定です。

## 必要な環境

- Docker Desktop
- Visual Studio Code
  - Remote Development（拡張機能）

## 技術スタック

このプロジェクトで使用した主な技術は以下の通りです：

- Docker: アプリケーションとその依存関係をコンテナとしてパッケージ化し、どの環境でも同じように動作するようにします。
- DevContainer: Visual Studio Codeの拡張機能で、Dockerコンテナ内で直接コードを書くことができます。これにより、開発環境のセットアップが容易になります。
- Golang: 静的型付け、コンパイル言語で、シンプルさと効率性を重視して設計されています。並行処理をサポートしており、Webサーバーなどのネットワークアプリケーションの開発に適しています。
- gin: Goで書かれた高速なHTTP Webフレームワークで、マルチプレクサ機能やミドルウェアサポートなどを提供します。
- gorm: Goで書かれたORM（Object-Relational Mapping）ライブラリで、データベース操作を簡単に行うことができます。

## 内容

### フォルダツリー

```
compose.yml
.env
.gitignore
├─.devcontainer
├─Dockerfile
├.dockerignore
└─src
    ├─bin           ビルドファイルを配置
    ├─cmd           エントリポイントファイルを配置
    │  ├─api        apiサーバーエントリポイント
    │  └─seeder     seederエントリポイント
    ├─controllers   コントローラー
    ├─database      DBアクセス
    ├─models        モデル
    ├─routes        ルーティング
    ├─go.mod
    ├─go.sum
    ├─.gitignore
    └─makefile
└─Dockerfile
```

### compose.ymlを作る

```yml
version: '3.8'
services:
  frontend:
    # 省略
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    volumes:
      - ./backend/src:/app
    ports:
      - 3001:3001
    tty: true
    env_file:
      - .env
    environment:
      - HOST=0.0.0.0
      - port=80
      - CHOKIDAR_USEPOLLING=true
    depends_on:
      - mysql
  mysql:
    image: mysql:8.0
    command: 
      - --default-authentication-plugin=mysql_native_password
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_general_ci
    environment:
      MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
      MYSQL_DATABASE: "${DB_NAME}"
      MYSQL_USER: "${DB_USER}"
      MYSQL_PASSWORD: "${DB_USER_PASSWORD}"
      MYSQLD_PUBLIC_KEY_RETRIEVAL: "true"
    ports:
      - 33306:3306
    volumes:
      - mysql-data:/var/lib/mysql
volumes:
  frontend_node_modules:
  mysql-data:
```

### backend/Dockerfileを作る

```sh
FROM golang:alpine3.19

WORKDIR /app

ENV CGO_ENABLED 0
ENV GOPATH /go
ENV GOCACHE /go-build

COPY . .

RUN go install -v golang.org/x/tools/gopls@latest && \
  go install -v github.com/cweill/gotests/gotests@v1.6.0 && \
  go install -v github.com/fatih/gomodifytags@v1.16.0 && \
  go install -v github.com/josharian/impl@v1.1.0 && \
  go install -v github.com/haya14busa/goplay/cmd/goplay@v1.0.0 && \
  go install -v github.com/go-delve/delve/cmd/dlv@latest && \
  go install -v honnef.co/go/tools/cmd/staticcheck@latest && \
  go install -v github.com/go-delve/delve/cmd/dlv@latest && \
  go install golang.org/x/tools/cmd/goimports@latest
```

### DB接続情報ファイル/.envファイルを作る

{}を任意の値で書き換える ※かっこは不要
```sh
DB_NAME={application-name}
DB_USER={user-name}
DB_USER_PASSWORD={user-password}
DB_HOST=mysql # dockerのservice name
MYSQL_ROOT_PASSWORD={root-password}
```

- /.gitignoreファイルを作る

```
.env
```

### devcontainerの準備

- backend/.devcontainer/devcontainer.jsonを作る

```json
{
  "name": "backend",
  "dockerComposeFile": "../../compose.yml",
  "service": "backend",
  "workspaceFolder": "/app",
  "customizations": {
    "vscode": {
      "settings": {
        "terminal.integrated.shell.linux": "/bin/bash"
      },
      "extensions": [
        "golang.go",
      ]
    }
  }
}
```

### コンテナ作成と起動

- ターミナルを開いてコンテナ作成と起動

```sh
docker compose up -d
```

- VSCodeの新しいウィンドウをbackendフォルダで開く
- VSCodeの左下の緑の所をクリックして、「コンテナで再度開く」

### go.modを作成

- backend/go.modを作る

```sh
go mod init bookapp
```

- 必要なモジュールを追加

```go
module bookapp

go 1.21.5

require (
	github.com/gin-gonic/gin v1.7.7
	github.com/jinzhu/gorm v1.9.16
	github.com/stretchr/testify v1.7.0
	gorm.io/gorm v1.22.4
)
```

### Hello world!

/main.go

```go
package main

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

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

	r.GET("/", func(c *gin.Context) {
		c.String(200, "Hello World!")
	})

	r.Run(":3001") // listen and serve on 0.0.0.0:3001
}
```

### モジュールインストール

```sh
go mod tidy
```

### helloworld起動

```
go run main.go
```

http://localhost:3001/

Hello World!の表示を確認

### DBを準備する

- modelを作る

```go
// models/book.go
package models

import (
    "gorm.io/gorm"
)

type Book struct {
    gorm.Model
    Title         string `gorm:"size:255;index"`
    Authors       string `gorm:"size:255;index"`
    Publisher     string `gorm:"size:255;index"`
    PublishedDate string `gorm:"size:255;index"`
    Description   string `gorm:"size:255"`
    Isbn          string `gorm:"size:255;unique"`
    PageCount     int
    ThumbnailUrl  string `gorm:"size:255"`
    Language      string `gorm:"size:255"`
    PreviewLink   string `gorm:"size:255"`
    HasPaper      bool   `gorm:"index"`
    HasEbook      bool   `gorm:"index"`
    PurchasePrice float64
    Memo          string `gorm:"type:text"`
}
```

- DBAccessモジュールを作る

initメソッドでbookモデルのマイグレーションが実行されます。
差分があったら実行されるそうです。

```go
// database/db_manager.go
package database

import (
	"bookapp/models"
	"log"

	"github.com/jinzhu/gorm"
)

type DBManagerInterface interface {
    // General
    Init()
    Seed() error

    // Book
    GetAllBooks() ([]models.Book, error)
    CreateBook(book *models.Book) error
    SearchBooks(keyword string) ([]models.Book, error)
    SortBooks(field string) ([]models.Book, error)
    FilterBooks(filter, value string) ([]models.Book, error)
    GetBookByISBN(isbn string) (*models.Book, error)    
}

type DBManager struct {
    db *gorm.DB
}

func NewDBManager(d *gorm.DB) DBManagerInterface {
    return &DBManager{db: d}
}

func (m *DBManager) Init() {
    m.db.AutoMigrate(&models.Book{})
}

func (m *DBManager) Seed() error {
    if err := m.seedBooks(); err != nil {
        log.Fatalf("Failed to seed books: %v", err)
    }
    return nil
}
```

- bookテーブルアクセス関数を作る

```go
// database/book.go
package database

import (
	"bookapp/models"
)

// CreateBook creates a new book in the database
func (m *DBManager) CreateBook(book *models.Book) error {
	return m.db.Create(book).Error
}

// GetBook returns a book with the given ID
func (m *DBManager) GetBook(id uint) (*models.Book, error) {
	var book models.Book
	err := m.db.First(&book, id).Error
	return &book, err
}

// UpdateBook updates a book in the database
func (m *DBManager) UpdateBook(book *models.Book) error {
	return m.db.Save(book).Error
}

// DeleteBook deletes a book with the given ID from the database
func (m *DBManager) DeleteBook(id uint) error {
	return m.db.Delete(&models.Book{}, id).Error
}

// GetAllBooks returns all books in the database
func (m *DBManager) GetAllBooks() ([]models.Book, error) {
	var books []models.Book
	err := m.db.Find(&books).Error
	return books, err
}

// SearchBooks returns books that match the given keyword
func (m *DBManager) SearchBooks(keyword string) ([]models.Book, error) {
	var books []models.Book
	err := m.db.Where("title LIKE ? OR author LIKE ? OR publisher LIKE ?", "%" + keyword + "%", "%" + keyword + "%", "%" + keyword + "%").Find(&books).Error
	return books, err
}

// SortBooks returns books sorted by the given field
func (m *DBManager) SortBooks(field string) ([]models.Book, error) {
	var books []models.Book
	err := m.db.Order(field).Find(&books).Error
	return books, err
}

// FilterBooks returns books that match the given filter condition
func (m *DBManager) FilterBooks(filter string, value string) ([]models.Book, error) {
	var books []models.Book
	err := m.db.Where(filter + " = ?", value).Find(&books).Error
	return books, err
}

// GetBookByISBN returns a book with the given ISBN
func (m *DBManager) GetBookByISBN(isbn string) (*models.Book, error) {
	var book models.Book
	err := m.db.Where("isbn = ?", isbn).First(&book).Error
	return &book, err
}
```

- bookControllerを作る

```go
// controllers/book_controller.go
package controllers

import (
	"bookapp/database"
	"bookapp/models"
	"github.com/gin-gonic/gin"
)

type BookController struct {
	DB database.DBManagerDBManagerInterface
}

func NewBookController(dbManager db.DBManagerDBManagerInterface) *BookController {
	return &BookController{
			DB: dbManager,
	}
}

func (ctrl *BookController) GetAllBooks(c *gin.Context) {
	books, err := ctrl.DB.GetAllBooks()
	if err != nil {
		c.JSON(500, gin.H{"error": err.Error()})
		return
	}
	c.JSON(200, books)
}

func (ctrl *BookController) CreateBooks(c *gin.Context) {
	var books []models.Book
	if err := c.ShouldBindJSON(&books); err != nil {
		c.JSON(400, gin.H{"error": err.Error()})
		return
	}
	for _, book := range books {
		if err := ctrl.DB.CreateBook(&book); err != nil {
			c.JSON(500, gin.H{"error": err.Error()})
			return
		}
	}
	c.JSON(200, books)
}

func (ctrl *BookController) SearchBooks(c *gin.Context) {
	keyword := c.Query("keyword")
	books, err := ctrl.DB.SearchBooks(keyword)
	if err != nil {
		c.JSON(500, gin.H{"error": err.Error()})
		return
	}
	c.JSON(200, books)
}

func (ctrl *BookController) SortBooks(c *gin.Context) {
	field := c.Query("field")
	books, err := ctrl.DB.SortBooks(field)
	if err != nil {
		c.JSON(500, gin.H{"error": err.Error()})
		return
	}
	c.JSON(200, books)
}

func (ctrl *BookController) FilterBooks(c *gin.Context) {
	filter := c.Query("filter")
	value := c.Query("value")
	books, err := ctrl.DB.FilterBooks(filter, value)
	if err != nil {
		c.JSON(500, gin.H{"error": err.Error()})
		return
	}
	c.JSON(200, books)
}

func (ctrl *BookController) GetBookByISBN(c *gin.Context) {
	isbn := c.Param("isbn")
	book, err := ctrl.DB.GetBookByISBN(isbn)
	if err != nil {
		c.JSON(500, gin.H{"error": err.Error()})
		return
	}
	c.JSON(200, book)
}
```

- bookRoutesを作る

```go
// routes/book_routes.go
package routes

import (
    "bookapp/controllers"
    "github.com/gin-gonic/gin"
)

func BookRoutes(r *gin.Engine, ctrl *controllers.BookController) {
    // Get all books
    r.GET("/books", ctrl.GetAllBooks)

    // Create new books
    r.POST("/books", ctrl.CreateBooks)

    // Search books by keyword
    r.GET("/books/search", ctrl.SearchBooks)

    // Sort books
    r.GET("/books/sort", ctrl.SortBooks)

    // Filter books
    r.GET("/books/filter", ctrl.FilterBooks)

    // Get a book by ISBN
    r.GET("/books/isbn/:isbn", ctrl.GetBookByISBN)
}
```

- apiのエントリポイントを作る

```go
// cmd/api/main.go
package main

import (
    "bookapp/database"
    "bookapp/routes"
    "fmt"
    "log"
    "os"

    "github.com/gin-gonic/gin"
    "github.com/jinzhu/gorm"
    _ "github.com/jinzhu/gorm/dialects/mysql"
)

func main() {
    dbUser := os.Getenv("DB_USER")
    dbPassword := os.Getenv("DB_USER_PASSWORD")
    dbHost := os.Getenv("DB_HOST")
    dbName := os.Getenv("DB_NAME")

    connectionString := fmt.Sprintf(
        "%s:%s@tcp(%s)/%s?charset=utf8&parseTime=True&loc=Local",
        dbUser,
        dbPassword,
        dbHost,
        dbName,
    )
    db, err := gorm.Open("mysql", connectionString)
    if err != nil {
        log.Fatal("Failed to connect to database: ", err)
    }
    defer db.Close()

    dbManager := database.NewDBManager(db)
    dbManager.Init()

    r := gin.Default()

    bookController := controllers.NewBookController(dbManager)
    routes.BookRoutes(r, bookController)
    
    r.Run(":3001")
}
```

- seed用のデータファイルを作る

```go
// database/seeds.go
package database

import (
	"time"
	"bookapp/models"
)

func (m *DBManager) seedBooks() error {
	sampleBooks := []models.Book{
		{
			Title:         "Sample Book 1",
			Authors:       "Sample Author 1",
			Publisher:     "Sample Publisher 1",
			PublishedDate: "2001-01-01",
			Description:   "Sample Description 1",
			Isbn:          "1234567890",
			PageCount:     200,
			ThumbnailUrl:  "http://example.com/sample1.jpg",
			Language:      "en",
			PreviewLink:   "http://example.com/sample1",
			HasPaper:      true,
			HasEbook:      false,
		},
		{
			Title:         "Sample Book 2",
			Authors:       "Sample Author 2",
			Publisher:     "Sample Publisher 2",
			PublishedDate: "2002-02-02",
			Description:   "Sample Description 2",
			Isbn:          "0987654321",
			PageCount:     300,
			ThumbnailUrl:  "http://example.com/sample2.jpg",
			Language:      "en",
			PreviewLink:   "http://example.com/sample2",
			HasPaper:      false,
			HasEbook:      true,
		},
	}

	for _, book := range sampleBooks {
		if err := m.db.FirstOrCreate(&book, models.Book{Isbn: book.Isbn}).Error; err != nil {
			return err
		}
	}

	return nil
}
```

- seed実行用のエントリポイントを作る

```go
// cmd/seeder/main.go
package main

import (
	"bookapp/database"
	"os"
	"fmt"
	"log"

	"github.com/jinzhu/gorm"
	_ "github.com/jinzhu/gorm/dialects/mysql"
)

func main() {
	dbUser := os.Getenv("DB_USER")
	dbPassword := os.Getenv("DB_USER_PASSWORD")
	dbHost := os.Getenv("DB_HOST")
	dbName := os.Getenv("DB_NAME")

	connectionString := fmt.Sprintf(
			"%s:%s@tcp(%s)/%s?charset=utf8&parseTime=True&loc=Local",
			dbUser,
			dbPassword,
			dbHost,
			dbName,
	)
	db, err := gorm.Open("mysql", connectionString)
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}
	defer db.Close()

	dbManager := database.NewDBManager(db)
	dbManager.Init()

	if err := dbManager.Seed(); err != nil {
		log.Fatalf("Failed to run seeders: %v", err)
	}
}
```

### makefile

```sh
.PHONY: build run dev seed

# ビルドターゲット
build:
	@go build -o ./bin/api ./cmd/api

# アプリケーションの実行
run:
	@./bin/app

# デバッグ用の実行
dev:
	@dlv debug ./cmd/api

# seederの実行
seed:
	@go run ./cmd/seeder/main.go

# テストの実行
test:
	@go test -v ./controllers

```

### ビルドしてサンプルデータ作って実行

```sh
make

make seed

make run
```

http://localhost:3001/books

seederで作ったBookデータ２件表示されれば成功

※他のエンドポイント未確認です。

## まとめ

Golang のシンプルな構文はすぐに慣れましたが、StructやInterfaceの使い方、レシーバーによる操作には少し苦労しました。
また、今回は結構 github copilot に助けてもらいました。
最後に、defar は、Golang の便利な機能ですが、他の言語でも対応してほしいと思いました。