0
0

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 1 year has passed since last update.

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

Posted at

はじめに

前回の記事では、家族向け書籍管理アプリのフロントエンドの環境構築と、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を作る

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を作る

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ファイルを作る

{}を任意の値で書き換える ※かっこは不要

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を作る
{
  "name": "backend",
  "dockerComposeFile": "../../compose.yml",
  "service": "backend",
  "workspaceFolder": "/app",
  "customizations": {
    "vscode": {
      "settings": {
        "terminal.integrated.shell.linux": "/bin/bash"
      },
      "extensions": [
        "golang.go",
      ]
    }
  }
}

コンテナ作成と起動

  • ターミナルを開いてコンテナ作成と起動
docker compose up -d
  • VSCodeの新しいウィンドウをbackendフォルダで開く
  • VSCodeの左下の緑の所をクリックして、「コンテナで再度開く」

go.modを作成

  • backend/go.modを作る
go mod init bookapp
  • 必要なモジュールを追加
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

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
}

モジュールインストール

go mod tidy

helloworld起動

go run main.go

Hello World!の表示を確認

DBを準備する

  • modelを作る
// 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モデルのマイグレーションが実行されます。
差分があったら実行されるそうです。

// 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テーブルアクセス関数を作る
// 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を作る
// 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を作る
// 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のエントリポイントを作る
// 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用のデータファイルを作る
// 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実行用のエントリポイントを作る
// 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

.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

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

make

make seed

make run

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

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

まとめ

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

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?