はじめに
- 参考文献にも掲載しています以下記事を自分の勉強用にアレンジしてまとめ直したものになります。こちらの記事の方がハイレベルな実装になっていますが、もう少し簡素化して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
何を作るか
- 簡単なタスク管理アプリを作成します。
フロントエンド(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-Type
がapplication/x-www-form-urlencoded
という形式になります。application/json
形式で送りたいかつaxiosを使う場合はオブジェクトとして直接渡してOKです。気になる方は本稿の「ちなみに」のプルリクを参照してください。
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)
// 省略
}
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.go
→router.go
→todo_controller.go
→todo_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を書いています。
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関数が実行され、ダミーデータが入るようにしています。初回のみ実行するイメージです。
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つのメソッドを持っていることしかしらず、具体的な実装内容を知りません。
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
になっていて、インターフェースであるところが重要。呼び出し元は呼び出される側の詳細な実装を知らない。
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
を持っている。型がインターフェースであるところが重要。
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のインスタンスを作成して注入していく。これで
Router
→Controller
→Model
の流れが出来上がる。 -
Constructor Injection
という依存性注入の方法らしい。この辺りもまだまだ要勉強。
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.go
のFetchTodos
、todo_controller.go
のFetchTodos
、todo_model
のFetchTodos
の順に実行されていく。
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メソッドで叩いているので、多分書かなくても動くっぽいのですが、念のため書いてます。
// 省略
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
を修正。
// 省略
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
を修正。
// 省略
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が叩かれてしまいます。
// 省略
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の実行結果)になっただけで大差ないですね。
// 省略
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
を修正。
// 省略
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)を生成しているという流れのようです。以下記事が参考になりました。
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(?, ?, ?)`
// 省略
}
ちなみに自分で思いついた訳もなく、リポジトリにあった以下のサンプルコードを改変して作りました。
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もやりたいな)