はじめに
前回の記事では、家族向け書籍管理アプリのフロントエンドの環境構築と、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 の便利な機能ですが、他の言語でも対応してほしいと思いました。