Go言語(Golang)入門 - REST APIの実装 -
はじめに
本記事では、Go言語とMySQLを用いてREST APIを作成する方法を紹介します。
Ginなどのフレームワークは使用せず、今回は標準パッケージでTODOアプリを作成します。
エディタはVSCodeを使用します。
環境構築
GoとMySQLの環境構築を行います。
Go
- 公式サイトからインストーラーをダウンロード
- インストーラーを起動し、インストール実施
- VSCodeにGoの拡張機能を追加
- Goのプロジェクト用に空のディレクトリを作成
今回は「backend」としておきます。 - 作成したディレクトリ直下で下記コマンド実行(初期化処理)
go mod init backend
MySQL
- 公式サイトからインストーラーをダウンロード
- インストーラーを起動し、インストール実施
- DB作成
CREATE DATABASE learning;
- 作成した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;
- ユーザー作成&パスワード設定
CREATE USER 'test'@'localhost' IDENTIFIED BY 'test';
- ユーザーにDB操作権限付与
GRANT ALL PRIVILEGES ON learning.* TO 'test'@'localhost'; FLUSH PRIVILEGES;
3から6に関しては、
インストール時に入る「MySQL 8.0 Command Line Client」から実行しても良いですし、
A5M2のようなツールを使ってもどちらでも良いです。
実装
基本的な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
よく見るクリーンアーキテクチャの図
domain [Enterprise Business Rules]
クリーンアーキテクチャの黄色の層になります。
ドメインロジック用のディレクトリです。
entitiesにはエンティティを定義し、repositoryにはインターフェースを定義します。
domain/entities/task.go
日付項目の型を「types.NullString」としているのは、
null許容のカラムの値をtime.Timeでマッピング(Scan)しようとした際にエラーになったため、
新しく型を定義して使用するようにしています。
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
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
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
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接続処理しかありませんが、データベースに関わる処理を書くところです。
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で定義しているインターフェースの実装を書いています。
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
ルーティングの設定を行います。
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コンテナを定義します。
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
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サーバーを起動します。
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でも削除されたデータは返ってきません。
Discussion