こんにちは。
今日はGo言語でCRUDのREST APIを作成していきます。
プロジェクト構成、単体テスト、Dockerイメージの作成など実際にREST APIを開発する上で必要だと思われる要素を盛り込みつつサンプルプロジェクトを作成していきます。
はじめに
プロジェクト構成、単体テスト、Dockerイメージの作成など実際にREST APIを開発する上で必要だと思われる要素を盛り込みつつサンプルプロジェクトを作成していきます。
今回はできるだけ外部ライブラリやフレームワークを使わずにGo言語の標準機能のみで開発しました。
これからバックエンドにGo言語を使用することを検討されている方の参考になれば幸いです。
※この記事は既にGo言語の開発環境をセットアップ済みで基本的な文法を学習済みの方を想定しています。
動作環境
PC : iMac
OS : macOS Ventura 13.3.1
Go : 1.20.6
Docker Desktop : 4.19.0 Engine : 23.0.5
MySQL : 8.0.33
プロジェクト構成
Go言語のプロジェクト構成はクリーンアーキテクチャの影響を受けているものが多い印象ですが、今回は慣れ親しんだMVCアーキテクチャ風の構成にしました。
go-crud-api/ ルートディレクトリ
┣ build/ Dockerfileなど
┃ ┣ db/ 動作確認用DB
┃ ┃ ┣ sql/ DDLとテストデータ投入用SQL
┃ ┃ ┗ Dockerfile
┃ ┣ sample-api/
┃ ┃ ┗ Dockerfile このサンプルプロジェクトの実行ファイルを含んだイメージを作成するためのDockerfile
┃ ┗ docker-compose.yml
┣ cmd/
┃ ┗ sample-api
┃ ┗ main.go メイン処理
┣ controller/
┃ ┣ dto/ リクエスト/レスポンス用のDTOファイルを配置する
┃ ┣ router.go HTTPメソッドを元にコントローラの各処理へのルーティングを行う
┃ ┣ router_test.go `router.go`のテストファイル
┃ ┣ todo_controller.go リクエストを元にモデルの各処理を呼び出しレスポンスを返却する
┃ ┗ todo_controller_test.go `todo_controller.go`のテストファイル
┣ model/
┃ ┣ entity/
┃ ┗ repository/
┣ test/
┗ mock.go 単体テスト用のモック
┣ test_results/ 単体テストのカバレッジファイルを配置する
┣ Makefile
┣ go.mod
┗ go.sum
Standard Go Project LayoutというGo言語でのメジャーなプロジェクトルールもありますが、規模の小さいプロジェクトだとやりすぎな感があるので、やはりプロジェクトの開発規模にあった構成を各自検討する必要がありそうです。
サンプルプロジェクト
このサンプルプロジェクトは基本的なCRUD操作を行うREST APIです。TODOアプリのバックエンドのイメージで、TODO(タイトルと内容)の取得/追加/更新/削除が行えます。
ここでは一部コードを抜粋して説明していきます。全ファイルは私のGithubリポジトリをご参照ください。
main.go
package main
import (
"net/http"
"github.com/pinpointdev90/go-crud-api/tree/main/controller"
"github.com/pinpointdev90/go-crud-api/tree/main/model/repository"
)
var tr = repository.NewTodoRepository()
var tc = controller.NewTodoController(tr)
var ro = controller.NewRouter(tc)
func main() {
server := http.Server{
Addr: ":8080",
}
http.HandleFunc("/todos/", ro.HandleTodosRequest)
server.ListenAndServe()
}
- 10~12行目はコンストラクタインジェクションでDIを行っています。
- 16行目はサーバが起動するポート番号を設定しています。この設定の場合、localhost:8080で起動します。
- 18行目はlocalhost:8080/todos/に届いたリクエストをHandleTodosRequestで処理するように設定しています。
- 19行目で実際にサーバが起動します。
database.go
package repository
import (
"database/sql"
"fmt"
)
var Db *sql.DB
func init() {
var err error
dataSourceName := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8",
"todo-app", "todo-password", "sample-api-db:3306", "todo",
)
Db, err = sql.Open("mysql", dataSourceName)
if err != nil {
panic(err)
}
}
- init()はパッケージの初期化処理などに使われます。このサンプルプロジェクトの場合github.com/pinpointdev90/go-crud-api/tree/main/model/repositoryがimportされたタイミングで動作し、main.goのメイン処理より先に実行されます。
- DSNは[username[:password]@][protocol[(address)]]/dbname[?param1=value1&...¶mN=valueN]の仕様に従って設定します。ここで設定しているusername、password、address、dbnameは動作確認用DBのdocker-compose.ymlとDockerfileの値を設定しています。またaddressは今回はコンテナ同士の通信になるので動作確認用DBのコンテナ名を設定します。その他のパラメータについてはgithub.com/go-sql-driver/mysqlをご参照ください。
- 15行目のようにドライバとDSNを指定するとDBのコネクションを取得できます。
router.go
package controller
import (
"net/http"
)
// 外部パッケージに公開するインタフェース
type Router interface {
HandleTodosRequest(w http.ResponseWriter, r *http.Request)
}
// 非公開のRouter構造体
type router struct {
tc TodoController
}
// Routerのコンストラクタ。引数にTodoControllerを受け取り、Router構造体のポインタを返却する。
func NewRouter(tc TodoController) Router {
return &router{tc}
}
func (ro *router) HandleTodosRequest(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
ro.tc.GetTodos(w, r)
case "POST":
ro.tc.PostTodo(w, r)
case "PUT":
ro.tc.PutTodo(w, r)
case "DELETE":
ro.tc.DeleteTodo(w, r)
default:
w.WriteHeader(405)
}
}
- HTTPメソッドを元にコントローラの各処理を呼び出すハンドラ関数です。不正なHTTPメソッドの場合は、405エラーを返却します。
以下はTODOのテーブル定義と投入するテストデータです。
db/sql/01_todo.sql
CREATE TABLE IF NOT EXISTS todo (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(40) NOT NULL,
content VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
updated_at TIMESTAMP NOT NULL DEFAULT current_timestamp ON UPDATE current_timestamp
)
db/sql/99_insert_data.sql
INSERT INTO todo (title, content) VALUES ('買い物', '今日の帰りに夕食の材料を買う');
INSERT INTO todo (title, content) VALUES ('勉強', 'TOEICの勉強を1時間やる');
INSERT INTO todo (title, content) VALUES ('ゴミ出し', '次の火曜日は燃えないゴミの日なので忘れないように');
以下は上記のテーブル定義を元に作成したDTOとEntityファイルです。
todo_dto.go
package dto
type TodoResponse struct {
Id int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
}
type TodoRequest struct {
Title string `json:"title"`
Content string `json:"content"`
}
type TodosResponse struct {
Todos []TodoResponse `json:"todos"`
}
- JSONデコード/エンコード用のDTOは構造体で定義します。構造体の各フィールドの末尾json:"XXX"のXXXがJSONのフィールド名になります。
todo_entity.go
package entity
type TodoEntity struct {
Id int
Title string
Content string
}
以下が実際にCRUD処理を行うコントローラとリポジトリです。
package controller
import (
"encoding/json"
"net/http"
"path"
"strconv"
"github.com/pinpointdev90/go-crud-api/tree/main/controller/dto"
"github.com/pinpointdev90/go-crud-api/tree/main/model/entity"
"github.com/pinpointdev90/go-crud-api/tree/main/model/repository"
)
// 外部パッケージに公開するインタフェース
type TodoController interface {
GetTodos(w http.ResponseWriter, r *http.Request)
PostTodo(w http.ResponseWriter, r *http.Request)
PutTodo(w http.ResponseWriter, r *http.Request)
DeleteTodo(w http.ResponseWriter, r *http.Request)
}
// 非公開のTodoController構造体
type todoController struct {
tr repository.TodoRepository
}
// TodoControllerのコンストラクタ。
// 引数にTodoRepositoryを受け取り、TodoController構造体のポインタを返却する。
func NewTodoController(tr repository.TodoRepository) TodoController {
return &todoController{tr}
}
// TODOの取得
func (tc *todoController) GetTodos(w http.ResponseWriter, r *http.Request) {
// リポジトリの取得処理呼び出し
todos, err := tc.tr.GetTodos()
if err != nil {
w.WriteHeader(500)
return
}
// 取得したTODOのentityをDTOに詰め替え
var todoResponses []dto.TodoResponse
for _, v := range todos {
todoResponses = append(todoResponses, dto.TodoResponse{Id: v.Id, Title: v.Title, Content: v.Content})
}
var todosResponse dto.TodosResponse
todosResponse.Todos = todoResponses
// JSONに変換
output, _ := json.MarshalIndent(todosResponse.Todos, "", "\t\t")
// JSONを返却
w.Header().Set("Content-Type", "application/json")
w.Write(output)
}
// TODOの追加
func (tc *todoController) PostTodo(w http.ResponseWriter, r *http.Request) {
// リクエストbodyのJSONをDTOにマッピング
body := make([]byte, r.ContentLength)
r.Body.Read(body)
var todoRequest dto.TodoRequest
json.Unmarshal(body, &todoRequest)
// DTOをTODOのEntityに変換
todo := entity.TodoEntity{Title: todoRequest.Title, Content: todoRequest.Content}
// リポジトリの追加処理呼び出し
id, err := tc.tr.InsertTodo(todo)
if err != nil {
w.WriteHeader(500)
return
}
// LocationにリソースのPATHを設定し、ステータスコード201を返却
w.Header().Set("Location", r.Host+r.URL.Path+strconv.Itoa(id))
w.WriteHeader(201)
}
// TODOの更新
func (tc *todoController) PutTodo(w http.ResponseWriter, r *http.Request) {
// URLのPATHに含まれるTODOのIDを取得
todoId, err := strconv.Atoi(path.Base(r.URL.Path))
if err != nil {
w.WriteHeader(400)
return
}
// リクエストbodyのJSONをDTOにマッピング
body := make([]byte, r.ContentLength)
r.Body.Read(body)
var todoRequest dto.TodoRequest
json.Unmarshal(body, &todoRequest)
// DTOをTODOのEntityに変換
todo := entity.TodoEntity{Id: todoId, Title: todoRequest.Title, Content: todoRequest.Content}
// リポジトリの更新処理呼び出し
err = tc.tr.UpdateTodo(todo)
if err != nil {
w.WriteHeader(500)
return
}
// ステータスコード204を返却
w.WriteHeader(204)
}
// TODOの削除
func (tc *todoController) DeleteTodo(w http.ResponseWriter, r *http.Request) {
// URLのPATHに含まれるTODOのIDを取得
todoId, err := strconv.Atoi(path.Base(r.URL.Path))
if err != nil {
w.WriteHeader(400)
return
}
// リポジトリの削除処理呼び出し
err = tc.tr.DeleteTodo(todoId)
if err != nil {
w.WriteHeader(500)
return
}
// ステータスコード204を返却
w.WriteHeader(204)
}
todo_controller.go
package repository
import (
"log"
_ "github.com/go-sql-driver/mysql"
"github.com/pinpointdev90/go-crud-api/tree/main/model/entity"
)
// 外部パッケージに公開するインタフェース
type TodoRepository interface {
GetTodos() (todos []entity.TodoEntity, err error)
InsertTodo(todo entity.TodoEntity) (id int, err error)
UpdateTodo(todo entity.TodoEntity) (err error)
DeleteTodo(id int) (err error)
}
// 非公開のTodoRepository構造体
type todoRepository struct {
}
// TodoRepositoryのコンストラクタ。TodoRepository構造体のポインタを返却する。
func NewTodoRepository() TodoRepository {
return &todoRepository{}
}
// TODO取得処理
func (tr *todoRepository) GetTodos() (todos []entity.TodoEntity, err error) {
todos = []entity.TodoEntity{}
// DBから全てのTODOを取得
rows, err := Db.
Query("SELECT id, title, content FROM todo ORDER BY id DESC")
if err != nil {
log.Print(err)
return
}
// 1行ごとTODOのEntityにマッピングし、返却用のスライスに追加
for rows.Next() {
todo := entity.TodoEntity{}
err = rows.Scan(&todo.Id, &todo.Title, &todo.Content)
if err != nil {
log.Print(err)
return
}
todos = append(todos, todo)
}
return
}
// TODO追加処理
func (tr *todoRepository) InsertTodo(todo entity.TodoEntity) (id int, err error) {
// 引数で受け取ったEntityの値を元にDBに追加
_, err = Db.Exec("INSERT INTO todo (title, content) VALUES (?, ?)", todo.Title, todo.Content)
if err != nil {
log.Print(err)
return
}
// created_atが最新のTODOのIDを返却
err = Db.QueryRow("SELECT id FROM todo ORDER BY id DESC LIMIT 1").Scan(&id)
return
}
// TODO更新処理
func (tr *todoRepository) UpdateTodo(todo entity.TodoEntity) (err error) {
// 引数で受け取ったEntityの値を元にDBを更新
_, err = Db.Exec("UPDATE todo SET title = ?, content = ? WHERE id = ?", todo.Title, todo.Content, todo.Id)
return
}
// TODO削除処理
func (tr *todoRepository) DeleteTodo(id int) (err error) {
// 引数で受け取ったIDの値を元にDBから削除
_, err = Db.Exec("DELETE FROM todo WHERE id = ?", id)
return
}
build/sample-api/Dockerfile
FROM alpine:3.18
RUN apk add --no-cache ca-certificates
ENV PATH /usr/local/go/bin:$PATH
ENV GOLANG_VERSION 1.20.6
WORKDIR /build
COPY ../../go.mod ../../go.sum ./
RUN go mod download
COPY ../../ ./
ARG CGO_ENABLED=0
ARG GOOS=linux
ARG GOARCH=amd64
RUN go build -ldflags '-s -w' ./cmd/sample-api
FROM alpine
COPY --from=builder /build/sample-api /opt/app/
ENTRYPOINT ["/opt/app/sample-api"]
Makefile
format:
@find . -print | grep --regex '.*\.go' | xargs goimports -w -local "github.com/pinpointdev90/go-crud-api/tree/main"
verify:
@staticcheck ./... && go vet ./...
unit-test:
@go test ./... -coverprofile=./test_results/cover.out && go tool cover -html=./test_results/cover.out -o ./test_results/cover.html
serve:
@docker-compose -f build/docker-compose.yml up
フォーマット、静的解析、単体テスト、起動の4つのコマンドを定義しています。
実行方法
Githubリポジトリからこのサンプルプロジェクトをダウンロード後任意のディレクトリに配置し、ルートディレクトリで下記コマンドを実行してください。
Makefileに記載されたコマンドが実行され、ビルドされた実行ファイルを含むDockerイメージを作成後、動作確認用DBと共にコンテナとして起動します。
make serve
フォアグラウンド処理となるので停止したい場合は、controlキー + cを押してください。
別のターミナルを立ち上げcurlコマンドを叩くと下記のようにTODOに対するCRUD操作が行えます。
[注意]もしM1チップ以外のMacで実行する場合は、下記ファイルの--platform=linux/amd64の部分を削除してください。
build/db/Dockerfile
FROM --platform=linux/amd64 library/mysql:8.0.25
ENV MYSQL_DATABASE todo
COPY custom.cnf /etc/mysql/conf.d/
COPY sql /docker-entrypoint-initdb.d
TODO取得
% curl -i localhost:8080/todos/
Content-Type: application/json
Content-Length: 346
[
{
"id": 3,
"title": "ゴミ出し",
"content": "次の火曜日は燃えないゴミの日なので忘れないように"
},
{
"id": 2,
"title": "勉強",
"content": "TOEICの勉強を1時間やる"
},
{
"id": 1,
"title": "買い物",
"content": "今日の帰りに夕食の材料を買う"
}
]
TODO追加
% curl -i -X POST -H "Content-Type: application/json" -d '{"title":"test", "content":"テストです。"}' localhost:8080/todos/
HTTP/1.1 201 Created
Location: localhost:8080/todos/4
Content-Length: 0
% curl -i localhost:8080/todos/
Content-Type: application/json
Content-Length: 425
[
{
"id": 4,
"title": "test",
"content": "テストです。"
},
{
"id": 3,
"title": "ゴミ出し",
"content": "次の火曜日は燃えないゴミの日なので忘れないように"
},
{
"id": 2,
"title": "勉強",
"content": "TOEICの勉強を1時間やる"
},
{
"id": 1,
"title": "買い物",
"content": "今日の帰りに夕食の材料を買う"
}
]
TODO更新
% curl -i -X PUT -H "Content-Type: application/json" -d '{"title":"test", "content":"変更テスト"}' localhost:8080/todos/4
HTTP/1.1 204 No Content
% curl -i localhost:8080/todos/
Content-Type: application/json
Content-Length: 422
[
{
"id": 4,
"title": "test",
"content": "変更テスト"
},
{
"id": 3,
"title": "ゴミ出し",
"content": "次の火曜日は燃えないゴミの日なので忘れないように"
},
{
"id": 2,
"title": "勉強",
"content": "TOEICの勉強を1時間やる"
},
{
"id": 1,
"title": "買い物",
"content": "今日の帰りに夕食の材料を買う"
}
]
TODO削除
% curl -i -X DELETE localhost:8080/todos/4
HTTP/1.1 204 No Content
% curl -i localhost:8080/todos/
Content-Type: application/json
Content-Length: 346
[
{
"id": 3,
"title": "ゴミ出し",
"content": "次の火曜日は燃えないゴミの日なので忘れないように"
},
{
"id": 2,
"title": "勉強",
"content": "TOEICの勉強を1時間やる"
},
{
"id": 1,
"title": "買い物",
"content": "今日の帰りに夕食の材料を買う"
}
]
単体テストについて
サンプルプロジェクトで作成した単体テストについて説明していきます。
controllerパッケージに関してはカバレッジ100%を満たすように単体テストを作成していますが、ここでは一番シンプルなrouter.goの単体テストを一部抜粋します。
テスト対象
router.goから一部抜粋
type Router interface {
HandleTodosRequest(w http.ResponseWriter, r *http.Request)
}
type router struct {
tc TodoController
}
func NewRouter(tc TodoController) Router {
return &router{tc}
}
func (ro *router) HandleTodosRequest(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
ro.tc.GetTodos(w, r)
case "POST":
ro.tc.PostTodo(w, r)
case "PUT":
ro.tc.PutTodo(w, r)
case "DELETE":
ro.tc.DeleteTodo(w, r)
default:
w.WriteHeader(405)
}
}
テスト用モック
mock.goから一部抜粋
package test
import (
"errors"
"net/http"
"github.com/pinpointdev90/go-crud-api/tree/main/model/entity"
)
type MockTodoController struct {
}
func (mtc *MockTodoController) GetTodos(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}
func (mtc *MockTodoController) PostTodo(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(201)
}
func (mtc *MockTodoController) PutTodo(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}
func (mtc *MockTodoController) DeleteTodo(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}
MockTodoControllerはTodoControllerのインタフェースに定義された関数を全て実装しています。処理はhttp.ResponseWriterにステータスコードを設定するのみです。
テストファイル
router_test.goから一部抜粋
package controller
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing" // Go言語標準のテスト用パッケージです。
"github.com/pinpointdev90/go-crud-api/tree/main/test"
)
// URLとハンドラ関数を関連付けるマルチプレクサと呼ばれる構造体。
// 複数のテストで共通して使うのでパッケージ変数として定義しています。
var mux *http.ServeMux
// 前/後処理のようなテストのフロー制御を行うための関数です。
// 前/後処理を行う必要がない場合は不要です。
func TestMain(m *testing.M) {
setUp()
// 各テストケースを実行します。今回だと`TestGetTodos`と`TestPostTodo`です。
code := m.Run()
os.Exit(code)
}
// 前処理用の関数です。関数名は他の名前でも問題ありません。
func setUp() {
// テスト用のモックをDIし`Router`のポインタを取得しています。
// `MockTodoController`は`TodoController`インタフェースの関数を全て実装しているのでDI可能です。
target := NewRouter(&test.MockTodoController{})
// テストを実行するマルチプレクサを生成
mux = http.NewServeMux()
// マルチプレクサにURLとテスト対象のハンドラ関数を関連付けます。
mux.HandleFunc("/todos/", target.HandleTodosRequest)
}
func TestGetTodos(t *testing.T) { // Goのテスト関数は`*testing.T`を引数に受け取ります。
// リクエストの生成
r, _ := http.NewRequest("GET", "/todos/", nil)
// レスポンスを取得するための処理
w := httptest.NewRecorder()
// テスト対象のハンドラ関数にリクエストを送信
mux.ServeHTTP(w, r)
// `MockTodoController`で設定しているステータスコード200が設定されていることを確認します。
if w.Code != 200 {
// ステータスコードが200以外が設定されている場合、
// テスト失敗なのでエラーを出力(後続のテストは継続される)
t.Errorf("Response cod is %v", w.Code)
}
}
func TestPostTodo(t *testing.T) {
// bodyにJSONを設定したリクエストの生成
json := strings.NewReader(`{"title":"test-title","content":"test-content"}`)
r, _ := http.NewRequest("POST", "/todos/", json)
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
if w.Code != 201 {
t.Errorf("Response cod is %v", w.Code)
}
}
テストの実行方法とカバレッジの出し方
テストは下記コマンドで実行します。
% go test # カレントディレクトリのファイルを対象に実行
% go test ./.. # カレントディレクトリ配下の全てのファイルを対象に実行
下記のオプションを指定するとカバレッジが出力されます。
% go test -cover
さらに下記コマンドを実行するとより詳細なカバレッジが出力されます。
% go test -coverprofile=cover.out
% go tool cover -html=cover.out -o cover.html
% open cover.html
このプロジェクトでの単体テストの実行方法
Makefileにコマンドを定義しているので、ルートディレクトリで下記コマンドを実行すると全てのテストを実行され、test_resultsフォルダ配下にhtml形式のカバレッジ測定結果が出力されます。
% make unit-test
標準機能のみの少ないコード量で複雑な設定ファイルなどもなく、簡単にAPIサーバが立ち上げれるのはGo言語の魅力の1つだと改めて思いました。
以上です。
ありがとうございます。