🔖

Go言語(Golang)入門 - REST APIの実装 -

に公開

はじめに

本記事では、Go言語とMySQLを用いてREST APIを作成する方法を紹介します。
Ginなどのフレームワークは使用せず、今回は標準パッケージでTODOアプリを作成します。
エディタはVSCodeを使用します。

環境構築

GoとMySQLの環境構築を行います。

Go

  1. 公式サイトからインストーラーをダウンロード
  2. インストーラーを起動し、インストール実施
  3. VSCodeにGoの拡張機能を追加
  4. Goのプロジェクト用に空のディレクトリを作成
    今回は「backend」としておきます。
  5. 作成したディレクトリ直下で下記コマンド実行(初期化処理)
    go mod init backend
    

参考: Windowsにgo言語をインストールする方法

MySQL

  1. 公式サイトからインストーラーをダウンロード
  2. インストーラーを起動し、インストール実施
  3. DB作成
    CREATE DATABASE learning;
    
  4. 作成したDBにテーブル作成
    CREATE TABLE tasks (
    	id int AUTO_INCREMENT
    	, task text NOT NULL
    	, content text
    	, deadline datetime
    	, status tinyint
    	, created_at datetime NOT NULL
    	, updated_at datetime
    	, deleted_at datetime
    	, PRIMARY KEY(id)
    ) DEFAULT CHARSET=utf8;
    
  5. ユーザー作成&パスワード設定
    CREATE USER 'test'@'localhost' IDENTIFIED BY 'test';
    
  6. ユーザーにDB操作権限付与
    GRANT ALL PRIVILEGES ON learning.* TO 'test'@'localhost';
    FLUSH PRIVILEGES;
    

3から6に関しては、
インストール時に入る「MySQL 8.0 Command Line Client」から実行しても良いですし、
A5M2のようなツールを使ってもどちらでも良いです。

参考: WindowsにMySQLをインストールする

実装

基本的なCRUD機能(登録、参照、更新、削除)を実装します。

APIエンドポイント

下記の通り実装しました。
削除に関しては、物理削除ではなく論理削除にしています。
PATCHを使うという意見もあると思いますが、私はDELETEを使う派です。

メソッド エンドポイント 機能
GET /tasks/ 全件取得
POST /tasks/ 新規登録
PATCH /tasks/{id} 更新
DELETE /tasks/{id} 論理削除

ディレクトリ構成

ディレクトリ構成は、クリーンアーキテクチャを意識しました。

backend
├─ go.mod
├─ go.sum
├─ cmd
│   └─ main.go
├─ controller
│   └─ task.go
├─ domain
│   ├─ entities
│   │   └─ task.go
│   └─ repository
│       └─ task.go
├─ infrastructure
│   ├─ database
│   │  ├─ database.go
│   │  └─ repository
│   │       └─ task.go
│   └─ router
│       └─ router.go
├─ registry
│   └─ registry.go
├─ types
│   └─ null_string.go
└─ usecase
    └─ task.go

よく見るクリーンアーキテクチャの図


引用元: The Clean Architecture

domain [Enterprise Business Rules]

クリーンアーキテクチャの黄色の層になります。
ドメインロジック用のディレクトリです。
entitiesにはエンティティを定義し、repositoryにはインターフェースを定義します。

domain/entities/task.go

日付項目の型を「types.NullString」としているのは、
null許容のカラムの値をtime.Timeでマッピング(Scan)しようとした際にエラーになったため、
新しく型を定義して使用するようにしています。

backend/domain/entities/task.go
package entities

import (
	"backend/types"
	"time"
)

type Task struct {
	Id        int              `json:"id"`
	Task      string           `json:"task"`
	Content   string           `json:"content"`
	Deadline  types.NullString `json:"deadline"`
	Status    int              `json:"status"`
	CreatedAt time.Time        `json:"created_at"`
	UpdatedAt types.NullString `json:"updated_at"`
	DeletedAt types.NullString `json:"deleted_at"`
}

domain/repository/task.go

backend/domain/repository/task.go
package repository

import (
	"backend/domain/entities"
)

type ITaskRepository interface {
	FindAll() ([]entities.Task, error)
	Create(task entities.Task) error
	Update(task entities.Task) error
	Delete(id int) error
}

usecase [Application Business Rules]

クリーンアーキテクチャの赤色の層になります。
アプリケーション固有のビジネスロジックを定義するディレクトリです。
今回は特に書くことはないので、インターフェースの実装を呼ぶだけになってます。

usecase/task.go

backend/usecase/task.go
package usecase

import (
	"backend/domain/entities"
	domainRepository "backend/domain/repository"
)

type TaskUseCaseImpl struct {
	TaskRepository domainRepository.ITaskRepository
}

type ITaskUseCase interface {
	FindAll() ([]entities.Task, error)
	Create(task entities.Task) error
	Update(task entities.Task) error
	Delete(id int) error
}

// コンストラクタ
func NewTaskUseCase(repository domainRepository.ITaskRepository) ITaskUseCase {
	return &TaskUseCaseImpl{
		TaskRepository: repository,
	}
}

// 全件取得
func (i *TaskUseCaseImpl) FindAll() ([]entities.Task, error) {
	return i.TaskRepository.FindAll()
}

// 新規登録
func (i *TaskUseCaseImpl) Create(task entities.Task) error {
	return i.TaskRepository.Create(task)
}

// 更新
func (i *TaskUseCaseImpl) Update(task entities.Task) error {
	return i.TaskRepository.Update(task)
}

// 削除
func (i *TaskUseCaseImpl) Delete(id int) error {
	return i.TaskRepository.Delete(id)
}

controller [Interface Adapters]

クリーンアーキテクチャの緑色の層になります。
外部からのデータの変換やユースケースの呼び出しなどを行います。

controller/task.go

backend/controller/task.go
package controller

import (
	"backend/domain/entities"
	"backend/types"
	"backend/usecase"
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"time"
)

type TaskRequest struct {
	Task     string `json:"task"`
	Content  string `json:"content"`
	Deadline string `json:"deadline"`
	Status   int    `json:"status"`
}

type TaskResponse struct {
	Id        int              `json:"id"`
	Task      string           `json:"task"`
	Content   string           `json:"content"`
	Deadline  types.NullString `json:"deadline"`
	Status    int              `json:"status"`
	CreatedAt time.Time        `json:"created_at"`
	UpdatedAt types.NullString `json:"updated_at"`
	DeletedAt types.NullString `json:"deleted_at"`
}

type TaskController struct {
	TaskUseCase usecase.ITaskUseCase
}

// コンストラクタ
func NewTaskController(taskUseCase usecase.ITaskUseCase) TaskController {
	return TaskController{
		TaskUseCase: taskUseCase,
	}
}

// 全件取得
func (c *TaskController) FindAll(w http.ResponseWriter, r *http.Request) {
	tasks, err := c.TaskUseCase.FindAll()
	if err != nil {
		http.Error(w, fmt.Sprintf("Failed to fetch tasks: %s", err.Error()), http.StatusInternalServerError)
		return
	}

	var res []TaskResponse
	for _, v := range tasks {
		task := TaskResponse{
			Id:        v.Id,
			Task:      v.Task,
			Content:   v.Content,
			Deadline:  v.Deadline,
			Status:    v.Status,
			CreatedAt: v.CreatedAt,
			UpdatedAt: v.UpdatedAt,
			DeletedAt: v.DeletedAt,
		}
		res = append(res, task)
	}

	json.NewEncoder(w).Encode(res)
}

// 新規登録
func (c *TaskController) Create(w http.ResponseWriter, r *http.Request) {
	var req TaskRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}

	task := entities.Task{
		Task:     req.Task,
		Content:  req.Content,
		Deadline: types.NewNullString(req.Deadline),
		Status:   req.Status,
	}

	if err := c.TaskUseCase.Create(task); err != nil {
		http.Error(w, fmt.Sprintf("Failed to create tasks: %s", err.Error()), http.StatusInternalServerError)
		return
	}
}

// 更新
func (c *TaskController) Update(w http.ResponseWriter, r *http.Request) {
	taskId, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/tasks/"))
	if err != nil {
		http.Error(w, "Invalid path", http.StatusBadRequest)
		return
	}

	var req TaskRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}

	task := entities.Task{
		Id:       taskId,
		Task:     req.Task,
		Content:  req.Content,
		Deadline: types.NewNullString(req.Deadline),
		Status:   req.Status,
	}

	if err := c.TaskUseCase.Update(task); err != nil {
		http.Error(w, fmt.Sprintf("Failed to update tasks: %s", err.Error()), http.StatusInternalServerError)
		return
	}
}

// 削除
func (c *TaskController) Delete(w http.ResponseWriter, r *http.Request) {
	taskId, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/tasks/"))
	if err != nil {
		http.Error(w, "Invalid path", http.StatusBadRequest)
		return
	}

	if err := c.TaskUseCase.Delete(taskId); err != nil {
		http.Error(w, fmt.Sprintf("Failed to delete tasks: %s", err.Error()), http.StatusInternalServerError)
		return
	}
}

infrastructure [Frameworks & Drivers]

クリーンアーキテクチャの青色の層になります。
データベースや外部リソースとの連携を行います。

database/database.go

今回はDB接続処理しかありませんが、データベースに関わる処理を書くところです。

backend/infrastructure/database/database.go
package database

import (
	"database/sql"

	"github.com/go-sql-driver/mysql"
)

// DB接続
func Connect() (*sql.DB, error) {
	config := mysql.NewConfig()
	config.User = "test"
	config.Passwd = "test"
	config.Addr = "localhost:3306"
	config.DBName = "learning"
	config.ParseTime = true

	return sql.Open("mysql", config.FormatDSN())
}

database/repository/task.go

domain/repositoryで定義しているインターフェースの実装を書いています。

参考: Golang開発者のためのクリーンアーキテクチャ

backend/infrastructure/database/repository/task.go
package repository

import (
	"backend/domain/entities"
	domainRepository "backend/domain/repository"
	"database/sql"
)

type taskRepository struct {
	db *sql.DB
}

// コンストラクタ
func NewTaskRepository(db *sql.DB) domainRepository.ITaskRepository {
	return &taskRepository{db: db}
}

// 全件取得
func (r *taskRepository) FindAll() ([]entities.Task, error) {
	sql := `
	SELECT * FROM tasks
	WHERE
		deleted_at IS NULL
	ORDER BY deadline ASC
	`
	rows, err := r.db.Query(sql)

	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var tasks []entities.Task
	for rows.Next() {
		var task entities.Task

		err := rows.Scan(
			&task.Id,
			&task.Task,
			&task.Content,
			&task.Deadline,
			&task.Status,
			&task.CreatedAt,
			&task.UpdatedAt,
			&task.DeletedAt,
		)
		if err != nil {
			return nil, err
		}
		tasks = append(tasks, task)
	}

	return tasks, nil
}

// 新規登録
func (r *taskRepository) Create(task entities.Task) error {
	sql := `
	INSERT INTO tasks (task, content, deadline, status, created_at, updated_at)
	VALUES (?, ?, ?, ?, NOW(), NOW())
	`
	_, err := r.db.Exec(sql, task.Task, task.Content, task.Deadline, task.Status)
	return err
}

// 更新
func (r *taskRepository) Update(task entities.Task) error {
	sql := `
	UPDATE tasks
	SET
		task = ?
		, content = ?
		, deadline = ?
		, status = ?
		, updated_at = NOW()
	WHERE
		id = ?
	`
	_, err := r.db.Exec(sql, task.Task, task.Content, task.Deadline, task.Status, task.Id)
	return err
}

// 削除
func (r *taskRepository) Delete(id int) error {
	sql := `
	UPDATE tasks
	SET
		status = 9
		, updated_at = NOW()
		, deleted_at = NOW()
	WHERE
		id = ?
	`
	_, err := r.db.Exec(sql, id)
	return err
}

router/router.go

ルーティングの設定を行います。

backend/infrastructure/router/router.go
package router

import (
	"backend/controller"
	"net/http"
)

type Router struct {
	TaskController controller.TaskController
}

// コンストラクタ
func NewRouter(taskController controller.TaskController) Router {
	return Router{
		TaskController: taskController,
	}
}

// ルーティング設定
func (router *Router) SetupRoutes() {
	http.HandleFunc("/tasks/", func(w http.ResponseWriter, r *http.Request) {
		switch r.Method {
		case http.MethodGet:
			router.TaskController.FindAll(w, r)
		case http.MethodPost:
			router.TaskController.Create(w, r)
		case http.MethodPatch:
			router.TaskController.Update(w, r)
		case http.MethodDelete:
			router.TaskController.Delete(w, r)
		}
	})
}

registry

自作のDIコンテナを定義します。

参考: Goにおける「俺的」クリーンアーキテクチャ構成

registry/registry.go

backend/registry/registry.go
package registry

import (
	"backend/controller"
	domainRepository "backend/domain/repository"
	infrastructureRepository "backend/infrastructure/database/repository"
	"backend/infrastructure/router"
	"backend/usecase"
	"database/sql"
)

type Registry struct {
	Router         router.Router
	TaskController controller.TaskController
	TaskRepository *domainRepository.ITaskRepository
	TaskUseCase    usecase.ITaskUseCase
}

// コンストラクタ
func NewRegistry(db *sql.DB) *Registry {
	r := &Registry{}
	r.init(db)
	return r
}

func (r *Registry) init(db *sql.DB) {
	taskRepository := infrastructureRepository.NewTaskRepository(db)
	taskUseCase := usecase.NewTaskUseCase(taskRepository)

	r.TaskRepository = &taskRepository
	r.TaskUseCase = taskUseCase

	r.TaskController = controller.NewTaskController(taskUseCase)

	r.Router = router.NewRouter(r.TaskController)
}

types

null許容のtime.Timeのマッピングでエラーになったため、
恐らく他の型でも同様の事象になると思い、型の定義用のディレクトリを用意しました。

参考: MySQL で sql.NullString なあいつを JSON に Marshalling する

types/null_string.go

backend/types/null_string.go
package types

import (
	"database/sql"
	"encoding/json"
)

type NullString struct {
	sql.NullString
}

// コンストラクタ
func NewNullString(s string) NullString {
	return NullString{sql.NullString{String: s, Valid: s != ""}}
}

func (s NullString) MarshalJSON() ([]byte, error) {
	return json.Marshal(s.String)
}

func (s *NullString) UnmarshalJSON(data []byte) error {
	var str string
	if err := json.Unmarshal(data, &str); err != nil {
		return err
	}
	s.String = str
	s.Valid = str != ""

	return nil
}

main.go

DB接続やDIコンテナの初期化、ルーティング設定を行い、Webサーバーを起動します。

backend/cmd/main.go
package main

import (
	mysql "backend/infrastructure/database"
	"backend/registry"
	"log"
	"net/http"

	_ "github.com/go-sql-driver/mysql"
)

func main() {
	// DB接続
	db, err := mysql.Connect()
	if err != nil {
		log.Fatalf("Database connection error: %v", err)
	}

	// DIコンテナの初期化
	registry := registry.NewRegistry(db)

	// ルーティング設定
	registry.Router.SetupRoutes()

	// Webサーバー起動
	http.ListenAndServe(":8080", nil)
}

動作確認

main.goを実行し、サーバーを起動します。

go run main.go

APIの動作確認は、curlコマンドで呼んでも良いですが、
私はChromeの拡張機能の「API Tester」を使用しています。

新規登録

現状タスクは登録されていないので、まずは「新規登録」を行います。
アドレスはhttp://localhost:8080/tasks/で、メソッドは「POST」を指定します。
Bodyには登録する内容をJson形式で指定します。

無事登録されました。

全件取得

次は先ほど登録した内容を取得してみます。
アドレスはhttp://localhost:8080/tasks/で、メソッドは「GET」を指定します。
APIを呼び出すと、先ほど登録した内容が取得できました。

更新

先ほど登録の際に「deadline」を指定していなかったので、日付を入れて更新してみます。
アドレスはhttp://localhost:8080/tasks/1で、メソッドは「PATCH」を指定します。
Bodyには登録する内容をJson形式で指定します。

更新されました。

削除

最後に削除してみます。
アドレスはhttp://localhost:8080/tasks/1で、メソッドは「DELETE」を指定します。

delete_atに日付が入ったので、論理削除されました。

全件取得のAPIでも削除されたデータは返ってきません。

91works Tech Blog

Discussion