67
Help us understand the problem. What are the problem?

posted at

updated at

Organization

GoでCRUD処理のREST APIを作ろう〜Reactを添えて〜

はじめに

  • 参考文献にも掲載しています以下記事を自分の勉強用にアレンジしてまとめ直したものになります。こちらの記事の方がハイレベルな実装になっていますが、もう少し簡素化してGoでAPIを作る大枠を理解したいと思ったのが執筆の動機です。そのためフロント側は簡単な説明のみです。ご了承ください。

  • バックエンド側はDockerを使用していますが、環境構築方法について本稿では触れません。こちらは以下記事をちょろっとカスタムして作っています。

  • 網羅的な説明はしていないので、まずリポジトリをざっと見て処理の流れを追ってもらった上で読み進めていただいた方が読みやすいと思います。README.mdにも起動方法を記載しております。masterブランチは記事よりも手を加えてしまっているので、for_qiitaブランチを参照ください。

  • 前回ポインタに関する記事を書いた際、非常に参考になるコメントを多数いただけました。今回ももちろんコメント大歓迎です!リポジトリへのプルリク投げていただくのも大歓迎です!

実行環境

  • PC : MacBookAir(M1, 2020)
  • OS : macOS Big Sur11.4
  • チップ : Apple M1
  • DockerDesktop : 3.5.2
  • Docker : 20.10.7
  • Go : 1.18 

何を作るか

  • 簡単なタスク管理アプリを作成します。
    • 完成イメージ
      スクリーンショット 2022-05-05 0.41.10.png
    • 【要件1】ページ遷移時にデータベースから既存のタスクがfetchされている。
    • 【要件2】タスクを追加することができる。
    • 【要件3】「変更する」ボタンを押下すると状況が「作業中」と「完了」のトグルで入れ替わる。
    • 【要件4】「削除する」ボタンを押下するとタスクが削除される。

フロントエンド(React)

ディレクトリ構成

ぶっちゃけ今回程度のアプリならdashboard.tsxに全部書いて良いレベルだと思いますが、一応分けました。

├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│   └── index.html
├── src
│   ├── App.tsx
│   ├── components           // page層で使用するコンポーネントを格納。
│   │   ├── InputForm.tsx
│   │   └── TodoList.tsx
│   ├── constants            // 定数を格納。
│   │   └── index.ts
│   ├── index.css
│   ├── index.tsx
│   ├── libs                 // axiosのようなライブラリのファイルを格納。
│   │   └── axios.ts
│   ├── pages                // page層。URLと対になる。
│   │   └── dashboard.tsx
│   ├── provider             // グローバルステートのファイル(contextAPI)を格納。
│   │   └── TodoProvider.tsx
│   ├── react-app-env.d.ts
│   ├── routes               // react-routerのファイルを格納。
│   │   └── index.tsx
│   ├── setupTests.ts
│   └── types                // 型定義を格納。
│       └── index.ts
├── tailwind.config.js
└── tsconfig.json

注意点

あまり意識したことがなかったのですが、フロントからPOSTを投げる際にContent-Typeを意識しないとGo側で読み取れないようです。フロント側のリクエストのペイロードには値があるのに、バックエンド側で読み取れていない場合はここを疑うと良さそうです。詳しくは以下記事参照ください。HTTP通信自体が理解できていないと反省。

  • 【2022年5月16日追記】
    URLSearchParamsを使ってリクエストを投げると、リクエストヘッダーのContent-Typeapplication/x-www-form-urlencodedという形式になります。application/json形式で送りたいかつaxiosを使う場合はオブジェクトとして直接渡してOKです。気になる方は本稿の「ちなみに」のプルリクを参照してください。

src/components/TodoList.tsx
const addTodo = async () => {
    const todo = {
      name: todoName,
      status: WORK_ON_PROGRESS,
    }
    const body = new URLSearchParams(todo)
    await client.post('add-todo', body)
    
    // オブジェクトを直接渡すと、フロント側のリクエストのペイロードには表示されるが、バックエンド側で読み取れなかった。
    const todo = {
      name: todoName,
      status: WORK_ON_PROGRESS,
    }
    await client.post('add-todo', todo)
    
    // 省略
  }
todo_model.go
func (tm *todoModel) AddTodo(r *http.Request) (sql.Result, error) {
	err := r.ParseForm() // Content-Typeによってパースするメソッドを変更する必要がある。

	// 省略

	req := Todo{
		Id:     id.String(),
		Name:   r.FormValue("name"), // ここで値が読み取れていない場合はContent-Typeを疑う。
		Status: r.FormValue("status"),
	}

	sql := `INSERT INTO todos(id, name, status) VALUES(?, ?, ?)`

	result, err := Db.Exec(sql, req.Id, req.Name, req.Status)

	// 省略
}

バックエンド(Go)

ディレクトリ構成

冒頭にも引用した以下記事を参考にMVCで構築しました。
main.gorouter.gotodo_controller.gotodo_model.goの順に読んでもらえると流れが追えます。

以前MVCをノリで理解する記事を書いたので、MVCをざっと復習したい方は是非どうぞ。

├── docker-compose.yml
├── golang
│   ├── Dockerfile
│   └── app
│       ├── cmd
│       │   └── main.go
│       ├── controller
│       │   ├── router.go
│       │   └── todo_controller.go
│       ├── go.mod
│       ├── go.sum
│       └── model
│           ├── database.go
│           └── todo_model.go
└── mysql
    ├── .env
    ├── Dockerfile
    └── my.cnf

MySQLコンテナに入って、データベースを作成する。

  • MySQLコンテナに入り、SQLを実行しておく。テーブルとダミーデータはGo側で流し込むので、データベースだけ作ります。
$ docker compose up -d
$ docker compose exec db bash
$ mysql -u root -p
$ password:                           // mysql/.envに記載している内容。リポジトリ通りなら、root_password。
mysql> CREATE DATABASE test_database;
mysql> exit;
$ exit

データベースへ接続するModelを作成する

  • まずimportの中でmysqlのdriverをimportしています。コードの中で使用していませんが、importされた時点で"github.com/go-sql-driver/mysql"のinit関数が走り、よしなにやってくれるようです。mysql用の関数があるのではなく、あくまでimportするドライバーでデータベースが制御でき、どのデータベースアプリを使うかを知らずに実装できるのは凄く良いなと思いました。
  • 次に変数Dbを宣言します。外部からも参照するので大文字で始めます。
  • dsnとはデータソース名のことで、Golangに接続したいデータベースを伝えるためのものです。(ユーザ名:パスワード@tcp(データベースサーバ:ポート番号)/データベース名?charaset=文字コード)という書式で記載します。今回はDockerを使っていて、mysql/.envの環境変数をGoコンテナが読み込んでいるので、ユーザ名、パスワード、データベース名はos.Getenvで環境変数を読み込み、またデータベースサーバはdocker-compose.ymlのコンテナ名になるので、dbとしています。
  • sql.Openでデータベースと接続をし、Db.Ping()で疎通確認ができます。
  • driver同様、このdatabase.goも読み込まれた段階でinit関数が走ります。この中でテーブルを作成するSQLを書いています。
database.go
package model

import (
	"database/sql"
	"fmt"
	"os"

    // importされたタイミングでinit関数が実行される。
	_ "github.com/go-sql-driver/mysql"
)

// 外部からも参照するので大文字で始める。
var Db *sql.DB

func init() {
	var err error

	dsn := fmt.Sprintf("%s:%s@tcp(db:3306)/%s?charset=utf8", os.Getenv("MYSQL_USER"), os.Getenv("MYSQL_PASSWORD"), os.Getenv("MYSQL_DATABASE"))

    // データベースと接続。
	Db, err = sql.Open("mysql", dsn)

	// エラーハンドリング省略

    // 疎通確認を行う。
	err = Db.Ping()

	// エラーハンドリング省略

    // database.goがimportされたらinit関数が走り、このSQLが実行される。
	sql := `CREATE TABLE IF NOT EXISTS todos(
			id varchar(26) not null,
			name varchar(100) not null,
			status varchar(100) not null
		)`

	_, err = Db.Exec(sql)

	// エラーハンドリング省略

	fmt.Println("Connection has been established!")
}

main.goでdatabase.goをimportする。

  • 前述の通り、importされた時点でdatabase.goのinit関数が走ります。main.goのmain関数より先に走ります。fmt.Printlnとか仕込むと分かります。
  • go run cmd/main.go -option=migrateと実行すると、migrate関数が実行され、ダミーデータが入るようにしています。初回のみ実行するイメージです。
main.go
package main

import (
    // ここでdatabase.goがimportされて、database.goのinit関数が走る。
	"crud/model"
	"flag"
	"fmt"
)

func migrate() {
	sql := `INSERT INTO todos(id, name, status) VALUES('00000000000000000000000000','買い物', '作業中'),('00000000000000000000000001','洗濯', '作業中'),('00000000000000000000000002','皿洗い', '完了');`

	_, err := model.Db.Exec(sql)

	if err != nil {
		fmt.Println(err)
		return
	}

	fmt.Println("Migration is success!")
}

func main() {
	f := flag.String("option", "", "migrate database or not")
	flag.Parse()
    // go run cmd/main.go -option=migrateが実行された場合のみ、migrate関数を実行する。
	if *f == "migrate" {
		migrate()
	}
}

todoを扱うModelを作成する。

  • todo_model.goを作成。責務はデータベースとやり取りして値をcontrollerに返却することです。todo_model自体を構造体にしてメソッドを4つ書いて、インターフェースを満たすようにする。呼び出し元は構造体型ではなく、インターフェース型としてtodoModelを認識することになります。ここが重要なところで、呼び出し元はtodoModelが4つのメソッドを持っていることしかしらず、具体的な実装内容を知りません。
todo_model.go
package model

import (
	"database/sql"
	"math/rand"
	"net/http"
	"time"

	"github.com/oklog/ulid"
)

type TodoModel interface {
	FetchTodos() ([]*Todo, error)
	AddTodo(r *http.Request) (sql.Result, error)
	ChangeTodo(r *http.Request) (sql.Result, error)
	DeleteTodo(r *http.Request) (sql.Result, error)
}

type todoModel struct {
}

type Todo struct {
	Id     string `json:"id"`
	Name   string    `json:"name"`
	Status string    `json:"status"`
}

// ここの戻り値の型が構造体の型ではなく、インターフェースになっているのが大事なところ。
// 呼び出し元はFetchTodos等、4つのメソッドを持っていることしか知らない。具体的な実装内容を知らない。
func CreateTodoModel() TodoModel {
	return &todoModel{}
}

func (tm *todoModel) FetchTodos() ([]*Todo, error) {
	// 省略
}

func (tm *todoModel) AddTodo(r *http.Request) (sql.Result, error) {
	// 省略
}

func (tm *todoModel) ChangeTodo(r *http.Request) (sql.Result, error) {
	// 省略
}

func (tm *todoModel) DeleteTodo(r *http.Request) (sql.Result, error) {
	// 省略
}

todoを扱うControllerを作成。

  • todo_controller.goを作成。controllerではmodelで取得したデータをjson形式にして返却する責務を担う。コントローラーの構造体を作成。メソッドをインターフェースでくっつけておく。model同様ですね。
  • 構造体todoControllerはプロパティとして構造体todoModelを持っているところに注目。前述の通り、型はTodoModelになっていて、インターフェースであるところが重要。呼び出し元は呼び出される側の詳細な実装を知らない。
todo_controller.go
package controller

import (
	"crud/model"
	"encoding/json"
	"fmt"
	"net/http"
)

type TodoController interface {
	FetchTodos(w http.ResponseWriter, r *http.Request)
	AddTodo(w http.ResponseWriter, r *http.Request)
	ChangeTodo(w http.ResponseWriter, r *http.Request)
	DeleteTodo(w http.ResponseWriter, r *http.Request)
}

type todoController struct {
	tm model.TodoModel
}

// model同様、インターフェースが戻り値の型になっているところが肝。
func CreateTodoController(tm model.TodoModel) TodoController {
	return &todoController{tm}
}

func (tc *todoController) FetchTodos(w http.ResponseWriter, r *http.Request) {
	// 省略
}

func (tc *todoController) AddTodo(w http.ResponseWriter, r *http.Request) {
	// 省略
}

func (tc *todoController) ChangeTodo(w http.ResponseWriter, r *http.Request) {
	// 省略
}

func (tc *todoController) DeleteTodo(w http.ResponseWriter, r *http.Request) {
	// 省略
}

Routerを作成。

  • router.goを作成。リクエストのURLによってメソッドをハンドリング。
  • 構造体routerは構造体todoControllerを持っている。型がインターフェースであるところが重要。
router.go
package controller

import (
	"net/http"
	"os"
)

type Router interface {
	FetchTodos(w http.ResponseWriter, r *http.Request)
	AddTodo(w http.ResponseWriter, r *http.Request)
	DeleteTodo(w http.ResponseWriter, r *http.Request)
	ChangeTodo(w http.ResponseWriter, r *http.Request)
}

type router struct {
	tc TodoController
}

func CreateRouter(tc TodoController) Router {
	return &router{tc}
}

func (ro *router) FetchTodos(w http.ResponseWriter, r *http.Request) {
	// 省略
}

func (ro *router) AddTodo(w http.ResponseWriter, r *http.Request) {
	// 省略
}

func (ro *router) DeleteTodo(w http.ResponseWriter, r *http.Request) {
	// 省略
}

func (ro *router) ChangeTodo(w http.ResponseWriter, r *http.Request) {
	// 省略
}

エントリーポイント(main.go)でModel、Controller、Routerをつなぎ合わせる

  • Model、Controller、Routerのインスタンスを作成して注入していく。これでRouterControllerModelの流れが出来上がる。
  • Constructor Injectionという依存性注入の方法らしい。この辺りもまだまだ要勉強。

main.go
package main

import (
	"crud/controller"
	"crud/model"
	"flag"
	"fmt"
	"net/http"
)

// todoModelのインスタンスを作成。
var tm = model.CreateTodoModel()

// todoControllerのインスタンスを作成。todoModelを注入。
var tc = controller.CreateTodoController(tm)

// routerのインスタンスを作成。todoControllerを作成。
var ro = controller.CreateRouter(tc)

func migrate() {
	// 省略
}

func main() {
    // 省略
}

サーバを立てる

  • http.ListenAndServeでサーバを立てます。第一引数がポート番号、第二引数がサーバの設定です。第ニ引数はnilを渡すと、デフォルトのサーバ設定が使われます。かなり色々設定出来るみたいですが、とりあえず勉強中はデフォルトで使っちゃいましょう。詳細はドキュメントもしくは以下記事が詳しいです。記事は英語っぽいタイトルですが日本語なので読みやすいかと思います。

  • http.HandleFuncでエンドポイントを作ることが出来ます。第一引数にURL、第二引数に実行するメソッドを指定できます。例えばhttp.HandleFunc("/fetch-todos", ro.FetchTodos)とあるように、/fetch-todosが叩かれたら、router.goFetchTodostodo_controller.goFetchTodostodo_modelFetchTodosの順に実行されていく。
main.go
package main

// 省略

func main() {
    // 省略
	http.HandleFunc("/fetch-todos", ro.FetchTodos)
	http.HandleFunc("/add-todo", ro.AddTodo)
	http.HandleFunc("/delete-todo", ro.DeleteTodo)
	http.HandleFunc("/change-todo", ro.ChangeTodo)
	http.ListenAndServe(":8080", nil)
}

ちょっと休憩。

ここまでで下準備が完了です。
次からは実際にCRUDの処理を書いていきましょう。
とは言ってもRead処理の場合とそれ以外の場合の2パターンしかないので、気楽に行きましょう。

Read処理の場合(要件1)

FetchTodosのような実際にtodoの値をフロントに返却するAPIの作り方です。

  • まずrouter.goを修正。ちょっとハマったのですが、w.Header().Set("Access-Control-Allow-Headers", "*")を記載しないと、GET以外のメソッドで通信しようとすると、CORSエラーで弾かれてしまいました。正直あまり理解できていないので、MDNのリンクだけ置いておきます。FetchTodosはフロントからGETメソッドで叩いているので、多分書かなくても動くっぽいのですが、念のため書いてます。

router.go
// 省略

func (ro *router) FetchTodos(w http.ResponseWriter, r *http.Request) {
    // プリフライトリクエスト用に設定している。
	w.Header().Set("Access-Control-Allow-Headers", "*")

    // CORSエラー対策。APIを叩くフロント側のURLを渡す。
	w.Header().Set("Access-Control-Allow-Origin", os.Getenv("ORIGIN"))

    // 返却する値のContent-Typeを設定。
	w.Header().Set("Content-Type", "application/json")

    // controllerを呼び出す。
	ro.tc.FetchTodos(w, r)
}

// 省略
  • 次にtodo_controller.goを修正。
todo_controller.go
// 省略

func (tc *todoController) FetchTodos(w http.ResponseWriter, r *http.Request) {
    // modelのFetchTodosを実行。SQLを実行してtodosを取得。
	todos, err := tc.tm.FetchTodos()

	if err != nil {
		fmt.Fprint(w, err)
		return
	}

    // json形式に変換。
	json, err := json.Marshal(todos)

	if err != nil {
		fmt.Fprint(w, err)
		return
	}

    // レスポンスにtodosを入れる。jsonはそのままだとbyte型の配列なのでstring型へ変換。
	fmt.Fprint(w, string(json))
}

// 省略
  • 最後にtodo_model.goを修正。
todo_model.go
// 省略

func (tm *todoModel) FetchTodos() ([]*Todo, error) {
	sql := `SELECT id, name, status FROM todos`

    // fetchメソッドのようなSQLを実行してデータを取ってきたい場合はQueryメソッドを使う。
	rows, err := Db.Query(sql)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var todos []*Todo

    // 取ってきたtodoの数だけ構造体にはめ込んで、todosに入れていく。
	for rows.Next() {
		var (
			id, name, status string
		)

        // 取ってきたデータを宣言した変数にはめ込んでいく。
		if err := rows.Scan(&id, &name, &status); err != nil {
			return nil, err
		}

		todos = append(todos, &Todo{
			Id: id,
			Name:   name,
			Status: status,
		})
	}

    // 構造体の入った配列をcontrollerに返却する。json化してレスポンスに書き込むのはcontrollerの役目。
	return todos, nil
}

// 省略

Create、Update、Delete処理の場合(要件2〜4)

DeleteTodoのようなデータベースから値を取ってくるのではなく、データベースを操作するAPIを作りたい場合です。CreateもUpdateも同様なので、DeleteTodoを題材にします。

  • router.goを修正。
  • 前述の通り、GET通信以外の場合はpreflightと言うリクエストが走るようです。CORS関連のようで、この場合はcontrollerにアクセスせず、returnするようにしています。これを書かないとpreflightとPOST通信で二度APIが叩かれてしまいます。
router.go
// 省略

func (ro *router) DeleteTodo(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Access-Control-Allow-Headers", "*")
	w.Header().Set("Access-Control-Allow-Origin", os.Getenv("ORIGIN"))
	w.Header().Set("Content-Type", "application/json")

	// preflightでAPIが二度実行されてしまうことを防ぐ。
	if r.Method == "OPTIONS" {
		return
	}
	
	ro.tc.DeleteTodo(w, r)
}

// 省略
  • todo_controller.goを修正。
    返却するものがtodosではなく、result(SQLの実行結果)になっただけで大差ないですね。
todo_controller.go
// 省略

func (tc *todoController) DeleteTodo(w http.ResponseWriter, r *http.Request) {
	result, err := tc.tm.DeleteTodo(r)

	if err != nil {
		fmt.Fprint(w, err)
		return
	}

	json, err := json.Marshal(result)

	if err != nil {
		fmt.Fprint(w, err)
		return
	}

	fmt.Fprint(w, string(json))
}

// 省略
  • todo_model.goを修正。
todo_model.go
// 省略

func (tm *todoModel) DeleteTodo(r *http.Request) (sql.Result, error) {
	err := r.ParseForm()

	if err != nil {
		return nil, nil
	}

	sql := `DELETE FROM todos WHERE id = ?`

    // deleteメソッドのようなSQLを実行してデータベースの操作だけしたい場合はExec。
	result, err := Db.Exec(sql, r.FormValue("id"))

	if err != nil {
		return result, err
	}

	return result, nil
}

// 省略

参考までにulidの作り方

  • Createメソッドの時に一意のIDを振る必要があり、今回はgithub.com/oklog/ulidを用いました。

  • 詳しくないので簡単な補足になりますが、まずそもそもコンピューターはランダムな数値を生成する事が非常に苦手です。(多分無理?)
    そのためランダムっぽい材料を外部から持ち込む必要があり、この材料のことをseed(種のこと)と呼び、よく現在時刻が用いられます。
  • エントロピーとは乱雑さのことであり、seedを使って生成します。そしてこの乱雑さを使って、ランダムな数値(今回はulid)を生成しているという流れのようです。以下記事が参考になりました。

todo_model.go
package model

import (
	"database/sql"
	"math/rand"
	"net/http"
	"time"

	"github.com/oklog/ulid"
)

// 省略

func (tm *todoModel) AddTodo(r *http.Request) (sql.Result, error) {
	// 省略

    // 乱数のseedとして現在時刻を呼び出す。
	t := time.Now()

    // エントロピー(乱雑さ)を作成。
	entropy := ulid.Monotonic(rand.New(rand.NewSource(t.UnixNano())), 0)

    // ulidを作成する。
	id := ulid.MustNew(ulid.Timestamp(t), entropy)

	req := Todo{
        // Idは今生成したものであって、クライアントからのリクエストではないのでここに入れるかは悩んだが、まあヨシとする。
		Id:     id.String(),
		Name:   r.FormValue("name"),
		Status: r.FormValue("status"),
	}

	sql := `INSERT INTO todos(id, name, status) VALUES(?, ?, ?)`

	// 省略
}

ちなみに自分で思いついた訳もなく、リポジトリにあった以下のサンプルコードを改変して作りました。

sample.go
func ExampleULID() {
    // これだと固定のUnix時間なので、何度実行しても同じulidが生成される。
	t := time.Unix(1000000, 0)
	entropy := ulid.Monotonic(rand.New(rand.NewSource(t.UnixNano())), 0)
	fmt.Println(ulid.MustNew(ulid.Timestamp(t), entropy))
	// Output: 0000XSNJG0MQJHBF4QX1EFD6Y3
}

ちなみに

  • 社内に本記事を展開したところ、色々と指摘をもらいました。修正分については以下プルリクにまとめてあります。正直私も指摘を受けてパッと理解できなかったので、本記事の内容が大体理解できたら、是非覗いてみてください。ただバックエンド経験が豊富でGoの基本だけさらっているという方ならいきなりこちらのプルリク見ていただいてもOKだと思います。

終わりに、そして次回予告

Golangの基礎的な勉強が一通り終わったので、今回は簡単なアプリ制作をしてみました。
次回は今回踏み込めなかったユニットテストをやりたいと思います。(Goはもちろん、Reactもやりたいな)

参考文献

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
67
Help us understand the problem. What are the problem?