はじめに
弊社ではバックエンドにGo言語を採用しています。
ここ最近自チームにGo言語未経験で入社したメンバーがいたのでそんなメンバーに向けてGoの基本的な文法を学んだ次のステップとして簡単なAPIを作成するハンズオンの教材を書きました。
対象読者としてはA Tour of Goなどをやり基本的な文法は知っているが、動くものは一度も作ったことがないという人を対象にしています。
使用技術
- Go 1.21.5
- MySQL 5.7
- Docker v24.0.7
- Docker Compose v2.23.3
ソースコード
作成するAPIのエンドポイント一覧
今回は基本的なCRUDのAPIを実装していきます。
メソッド | エンドポイント | 説明 |
---|---|---|
GET |
/todos |
すべてのTODOを取得 |
GET |
/todos/:id |
特定のTODOを取得 |
POST |
/todos |
新しいTODOを作成 |
PUT |
/todos/:id |
TODOを更新 |
DELETE |
/todos/:id |
TODOを削除 |
プロジェクトのセットアップ
プロジェクトの命名は各自に任せますが今回はシンプルにgo-todo-app
という命名にしています。
$ mkdir go-todo-app
$ go mod init go-todo-app
go: creating new go.mod: module go-todo-app
これで準備ができたのでTODO APIを作っていきましょう!
1章 HTTPサーバーについて
- 第1章では
net/http
という標準パッケージを使用してHTTPサーバーをまず実装しその後Echoを使って書き換えていきたいと思います
1. net/http
を使った基本的なTODO APIの作成
1-1. 基本のHTTPサーバー
- まずはHTTPのリクエストを受け取ったら、
Hello World!
とレスポンスを返すHTTP サーバーを実装してみたいと思います
package main
import (
"io"
"log"
"net/http"
)
func main() {
http.HandleFunc("/hello", helloHandler)
log.Println("server start at port 8080")
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal(err)
}
}
func helloHandler(w http.ResponseWriter, req *http.Request) {
io.WriteString(w, "Hello, world!")
}
$ go run main.go
2025/02/11 16:04:52 server start at port 8080
$ curl http://localhost:8080/hello
Hello, world!
コード解説
簡単に解説をしていきます。
① ハンドラの定義
func helloHandler(w http.ResponseWriter, req *http.Request) {
// ハンドラ内で行う処理
}
- ここではHTTPリクエストを受け取りそのリクエストに対してHTTPレスポンスの内容をコネクションに書き込むハンドラを定義しています
- Goでハンドラを作る場合は、
- 引数に
http.ResponseWriter
型とhttp.Request
型をとり戻り値なし
- 引数に
という形にする必要があります。
② ハンドラ内でのレスポンスの処理
先ほど定義したhelloHandler
ハンドラの中で「どのようなレスポンスを返すのか」を定義していきます。
func helloHandler(w http.ResponseWriter, req *http.Request) {
io.WriteString(w, "Hello, world!")
}
Go のハンドラ関数では、
-
req *http.Request
からリクエストの情報を取得し、レスポンスの内容を決める -
w http.ResponseWriter
にレスポンスの内容を書き込む
という手順でレスポンスを返します。
ただ、今回のhelloHandler
関数では、リクエストの内容に関係なく、常に "Hello, World!" を返すシンプルな実装になっているため、1 は省略されています。
2について、レスポンスの書き込みには io.WriteString
関数を使用しています。
// io.WriteString 関数の実際の定義
func WriteString(w Writer, s string) (n int, err error)
io.WriteString
関数を見ていくと、この関数は、第一引数 w
(io.Writer
型)に、第二引数 s
(文字列)を書き込むという挙動をしています。
これをhelloHandler
に適用すると、w(http.ResponseWriter型)
に "Hello, World!"
という文字列を io.WriteString
を使って書き込むことで、クライアントに "Hello, World!"
というレスポンスが返されるようになっています。
func helloHandler(w http.ResponseWriter, req *http.Request) {
// 第1引数として渡されていた変数 w(http.ResponseWriter型) に
// "Hello, World!"と書き込む
io.WriteString(w, "Hello, world!")
}
インターフェースについて
ここで io.WriteString
の第一引数 w
は io.Writer
型ですが、実際には http.ResponseWriter
型の変数 w
を渡しています。「型が違うのに、なぜ渡せるの?」と疑問に思うかもしれませんが、これは インターフェース型を利用しているからです。
io.Writer
は、以下のように定義されています。
type Writer interface {
Write(p []byte) (n int, err error)
}
この定義は、「Write(p []byte) (n int, err error)
メソッドを持つ型であれば、io.Writer
として扱うことができる」という意味です。
例えば、以下のような構造体Struct1
は Write
メソッドを持っているため、io.Writer
型の変数に代入することができます。
type Struct1 struct{}
func (t Struct1) Write(p []byte) (n int, err error) {
// 省略
return len(p), nil
}
var w io.Writer
w = Struct1{} // OK
一方、Write
メソッドを持たない構造体 Struct2
は io.Writer
型として扱うことができません。
type Struct2 struct{}
var w io.Writer
w = Struct2{} // コンパイルエラー
今回の場合だとhttp.ResponseWriter
は Write(p []byte) (n int, err error)
メソッドを持っているため、io.Writer
インターフェースを満たしており、io.WriteString
の第一引数として使用することができるのです。
③ 定義したハンドラを登録
helloHandler
を定義しただけでは、サーバーはそのハンドラを認識しません。
サーバーにハンドラを登録するには、net/http
パッケージの HandleFunc
関数を使用します。
http.HandleFunc("/hello", helloHandler)
/hello
というパスにリクエストが来たときに helloHandler
が実行されるようになります。
④ サーバーの起動
サーバーを起動するには、net/http
パッケージの ListenAndServe
関数を使用します。
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal(err)
}
http.ListenAndServeの第一引数でサーバーの起動場所を定義しています。今回は8080
を指定しています。http.ListenAndServeでは戻り値がerror
なので、エラーが発生した場合には log.Fatal
でログを出力してプログラムを終了するようにしています。
まとめ
ここまで解説したものをまとめると以下のようになります。
package main
import (
"io"
"log"
"net/http"
)
func main() {
// ハンドラ関数を /hello というパスに登録
http.HandleFunc("/hello", helloHandler)
// サーバーが起動したことをログに出力
log.Println("server start at port 8080")
// サーバーを 8080 ポートで起動し、エラーが発生したらログを出力
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal(err)
}
}
// ハンドラ関数の定義
func helloHandler(w http.ResponseWriter, req *http.Request) {
// HTTP レスポンスとして "Hello, world!" を返す
io.WriteString(w, "Hello, world!")
}
1-2. 許可するHTTPメソッドの指定
基本のHTTPサーバーの実装方法が分かったので次は許可するHTTPメソッドの指定を行なっていきます。
現状のhelloHandler
関数ではどのHTTPリクエストを受け付けるようになってしまっています。
$ curl http://localhost:8080/hello -X GET
Hello, world!
$ curl http://localhost:8080/hello -X POST
Hello, world!
$ curl http://localhost:8080/hello -X PUT
Hello, world!
$ curl http://localhost:8080/hello -X DELETE
Hello, world!
特定のHTTPメソッドだけ受け付けるように実装していきましょう。
実装方針としては以下です。
- リクエストの内容からどのHTTPメソッドかを判断
- 定義したHTTPメソッドだった場合は正常応答を返却
- 定義したHTTPメソッドではない場合はエラーを返却
それでは実装していきます。
まず1の「リクエストの内容からどのHTTPメソッドかを判断」はハンドラの第二引数のreq
を参照することで実現できます。
func helloHandler(w http.ResponseWriter, req *http.Request) {
// reqにリクエスト情報が入っている
}
http.Request
の構造体を見ていくとMethodを持っています。
type Request struct {
// Method specifies the HTTP method (GET, POST, PUT, etc.).
// For client requests, an empty string means GET.
//
// Go's HTTP client does not support sending a request with
// the CONNECT method. See the documentation on Transport for
// details.
Method string
}
試しにログを仕込んでサーバーを起動しGETリクエストをしてみます。
func helloHandler(w http.ResponseWriter, req *http.Request) {
// io.WriteString(w, "Hello, world!")
fmt.Println("request: ", req.Method)
}
ログを確認すると、GETリクエストが確認できました。
$ curl http://localhost:8080/hello -X GET
// ログ
2025/02/15 17:10:32 server start at port 8080
request: GET
このreq.Method
を使用すれば1と2が一気に実装できそうなので実装していきます。
func helloHandler(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet {
io.WriteString(w, "Hello, world!")
}
}
http.MethodGet
はnet/http
パッケージの中にHTTPメソッドを定義する定数が存在するので表記揺れや仕様変更が起きた場合でも対応できるようにその定数を使用しています。
これで1と2が実装できたので最後に3の「定義したHTTPメソッドではない場合はエラーを返却」を実装していきます。
func helloHandler(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet {
io.WriteString(w, "Hello, world!")
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
シンプルにエラーを返却しても良かったのですが、net/http
パッケージに指定した任意のエラーコードを返却するhttp.Error
関数が存在するのでそちらを使用しています。
http.Error
関数は第2引数で指定したエラーメッセージを第3引数で指定したエラーのステータスコードと共に返却することができる関数です。
今回は405エラーを返すように実装しました。
動作確認をしていきましょう。
$ curl http://localhost:8080/hello -X GET -w '%{http_code}\n'
Hello, world!
200
$ curl http://localhost:8080/hello -X PUT -w '%{http_code}\n'
Method not allowed
405
$ curl http://localhost:8080/hello -X POST -w '%{http_code}\n'
Method not allowed
405
$ curl http://localhost:8080/hello -X DELETE -w '%{http_code}\n'
Method not allowed
405
curl コマンドにオプションをつけることで、返却されたステータスコードが分かるようにしています。GETリクエストのときにだけ正常応答である200が返却されていて、他は405ステータスコードが返ってきていることを確認することができました。
1-3. (実践演習) TODO APIの各エンドポイントを実装
ここまで解説したことを使って今回作成するTODO APIの各エンドポイントを実装していきます。
エンドポイント一覧
メソッド | エンドポイント | 説明 |
---|---|---|
GET |
/todos/list |
すべてのTODOを取得 |
GET |
/todos/:id |
特定のTODOを取得 |
POST |
/todos |
新しいTODOを作成 |
PUT |
/todos/:id |
TODOを更新 |
DELETE |
/todos/:id |
TODOを削除 |
実装方針としては以下です。
- 各エンドポイントのハンドラを作成
- 任意のHTTPメソッドのみを許可するように
- 許可したHTTPメソッドでない場合は405エラーを返却するように
- エンドポイント
/todos/:id
のパスパラメータに関してはここでは一旦適当なIDを指定しておく- パスパラメータの扱いに関しては後ほど解説します
4を踏まえて改めてここで実装するエンドポイントは以下です
メソッド | エンドポイント | 説明 |
---|---|---|
GET |
/todos/list |
すべてのTODOを取得 |
GET |
/todos/1 |
ID1のTODOを取得 |
POST |
/todos |
新しいTODOを作成 |
PUT |
/todos/2 |
ID2のTODOを更新 |
DELETE |
/todos/3 |
ID3のTODOを削除 |
それでは実装してみてください。解説した内容のみなのでそこまで難しくはないはず!
ここまでで処理が煩雑になってきたのでハンドラを別ファイルに切り出してリファクタもしてみましょう。
これで基本のTODO APIの実装ができました!
2. Echoを使ったHTTPサーバー
ここまでは標準パッケージであるnet/http
パッケージを使用してきましたが、ここからはEchoを使用していきます。
Echoとは、Go のWEBフレームワークでnet/http
と比較するとシンプルかつ直感的で高速なのでEchoを使用することで簡単にHTTPサーバーを作ることができます。
Echoの詳細な解説については他の方が解説してくれている記事がたくさんあるのでそちらを参考にしてみてください。
2-1 Echoの導入と基本の実装
それではまずEchoをインストールしていきます。
$ go get github.com/labstack/echo/v4
インストールできたら一番最初に実装したHTTP レスポンスとして "Hello, world!"
を返すサーバーをEchoを使用して実装していきます。
実装例
package main
import (
"github.com/labstack/echo/v4"
"net/http"
)
func main() {
e := echo.New()
e.GET("/hello", helloHandler)
e.Logger.Fatal(e.Start(":8080"))
}
func helloHandler(c echo.Context) error {
return c.String(http.StatusOK, "Hello, world!")
}
net/http
との違いに触れながら簡単に解説していきます。
① Echoのインスタンスを作成
e := echo.New()
この作成したインスタンスを使ってルーティングの設定やサーバーの起動など諸々行うわけです。
echoを使用するためのおまじないくらいに思っておくのが良さそうです。
② ルーティングの設定
e.GET("/hello", helloHandler)
e.GET()
を使って /hello
にGETリクエストが来たときに helloHandler
を実行 するように設定しています。
net/http
パッケージでは、HTTPメソッドで分岐する実装にしていましたが、EchoではルーティングにHTTPメソッドを指定することで指定したHTTPメソッド以外は405をレスポンスするようになっているので直感的で便利ですね!
HTTPメソッドで分岐をしていた例
func helloHandler(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet {
io.WriteString(w, "Hello, world!")
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
③ ハンドラ関数の定義
func helloHandler(c echo.Context) error {
return c.String(http.StatusOK, "Hello, world!")
}
net/http
パッケージで実装したハンドラと比べてみると、まずパラメータがw http.ResponseWriter
、req *http.Request
からc echo.Context
になっています。
元の実装:
func helloHandler(w http.ResponseWriter, req *http.Request) {
io.WriteString(w, "Hello, world!")
}
echo.Context
とは一言で言うとリクエストとレスポンスの情報をまとめて管理しているコンテキストでhttp.ResponseWriter
とhttp.Request
を内包しているようなイメージです。
例えばリクエストを取得したい場合は、Context
インターフェースのRequest()
メソッドを使用することで取得できたりします。
今回はio.WriteString
と同様テキストでのレスポンスを返したいのでContext
インターフェースのString()
メソッドを使用してHello, world!
がレスポンスされるようにしています。
2-2. (実践演習) Echoを使用してTODO APIのエンドポイントを書き換え
基本的なEchoの使い方が分かったところで、net/http
パッケージを使用して「1-3. (実践演習) TODO APIの各エンドポイントを実装」で実装した各エンドポイントを書き換えていきましょう。
net/http
パッケージで実装したものと比べるとかなりスッキリしたかと思います。
3. パスパラメータとクエリパラメータの取得
3-1. パスパラメータの取得方法
最後にパスパラメータの取得方法を学び暫定で設定した以下のエンドポイントに反映していきます。
メソッド | エンドポイント | 説明 |
---|---|---|
GET |
/todos/1 |
ID1のTODOを取得 |
PUT |
/todos/2 |
ID2のTODOを更新 |
DELETE |
/todos/3 |
ID3のTODOを削除 |
echoを使用してのパスパラメータの取得はとても簡単です。
echoではContext
インターフェースが提供しているParam()
というメソッドを使用することでパスパラメータから値を取得することができます。
例えばhelloHandlerでnameというパスパラメータを取得したい場合は以下のように実装します
例:/hello/:name
の場合
package main
import (
"github.com/labstack/echo/v4"
"net/http"
)
func main() {
e := echo.New()
// nameを指定
e.GET("/hello/:name", helloHandler)
e.Logger.Fatal(e.Start(":8080"))
}
func helloHandler(c echo.Context) error {
// パスパラメータ `name` を取得
name := c.Param("name")
return c.String(http.StatusOK, "Hello, " + name + "!")
}
注意しないといけないのはParam()
メソッドの実装を見るとstring
を返り値にしていることです。
func (c *context) Param(name string) string {
}
つまりクエリパラメータを数値として取得したい場合はstrconv.Atoi()
を使用して数値型に変換する必要があります。
例:/hello/:id
の場合
package main
import (
"github.com/labstack/echo/v4"
"net/http"
"strconv"
)
func main() {
e := echo.New()
// idを指定
e.GET("/hello/:id", helloHandler)
e.Logger.Fatal(e.Start(":8080"))
}
func helloHandler(c echo.Context) error {
// パスパラメータ `id` を取得
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
return c.String(http.StatusBadRequest, "Invalid ID")
}
return c.String(http.StatusOK, "Your ID is " + strconv.Itoa(id))
}
ちなみにクエリパラメータの取得をnet/httpパッケージのみで実現しようとした場合はURL解析を手動で行う必要があるのでParam()
はとてもシンプルですね。
func helloHandler(w http.ResponseWriter, r *http.Request) {
// パスから `name` を取得(正規表現やマニュアル解析が必要)
name := strings.TrimPrefix(r.URL.Path, "/hello/")
if name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return
}
io.WriteString(w, "Hello, " + name + "!")
}
3-2. (実践演習) 暫定実装部分の書き換え
それではParam()
メソッドを使用して以下のエンドポイントを書き換えていきましょう。
メソッド | エンドポイント | 説明 |
---|---|---|
GET |
/todos/:id |
特定のTODOを取得 |
PUT |
/todos/:id |
TODOを更新 |
DELETE |
/todos/:id |
TODOを削除 |
3-3 クエリパラメータの取得方法
今回実装はしていませんがクエリパラメータの扱いについても触れておきます。
クエリパラメータに関してもechoのContext
インターフェースが提供しているQueryParam()
メソッドを使用することで簡単に処理できます
例:/search?keyword=xxx
形式の場合
package main
import (
"github.com/labstack/echo/v4"
"net/http"
)
func main() {
e := echo.New()
e.GET("/search", searchHandler)
e.Logger.Fatal(e.Start(":8080"))
}
func searchHandler(c echo.Context) error {
// クエリパラメータ `keyword` を取得
keyword := c.QueryParam("keyword")
if keyword == "" {
return c.String(http.StatusBadRequest, "Keyword is required")
}
return c.String(http.StatusOK, "Searching for: " + keyword)
}
echoのContext
インターフェースのおかげで非常にシンプルに実装できることがよくわかったかと思います。
1章まとめ
この章で学んだことは以下です。
-
net/http
で基本的なサーバーを実装方法 -
interface
の役割とhttp.ResponseWriter
との関係について - Echoの導入と基本的な実装方法
-
net/http
とEchoの違いについて - パスパラメータやクエリパラメータの処理方法
第2章では構造体とJSONの扱いについて学んでいきましょう!
2章 構造体とjsonの扱い方
1章ではリクエストを受け取ったら文字列をレスポンスとして返すAPIを作成しました。
APIでは基本jsonを使用するので2章では1章で作ったAPIをjsonでやりとりできるようにまずはGoの標準パッケージであるencoding/json
を使用してjsonの扱いを学び、その後1章同様Echoでのjsonの扱い方を学び書き換えを行なっていきます。
1. 構造体の定義
jsonを使えるようにする前にまずはどのようなjsonの形にしてデータを扱うかを考える必要があります。
Goの構造体を使用してTODO APIで使用するデータ型を定義していきましょう。
1-1. データ型の定義
構造体を定義する前にまずは各エンドポイントでどのようなデータをレスポンスするのかを簡単に整理しました。
エンドポイント | 概要 | レスポンスに含めたい内容 |
---|---|---|
GET /todos/list |
すべてのTODOを取得 | TODOの一覧 |
GET /todos/:id |
特定のTODOを取得 | TODOの内容 |
POST /todos |
新しいTODOを作成 | 作成に成功したTODOの内容 |
PUT /todos/:id |
TODOを更新 | 更新に成功したTODOの内容 |
DELETE /todos/:id |
TODOを削除 | なし |
今回はシンプルなAPIなのでレスポンスに含めるのはTODOのみで良さそうですね。
だ本来であればTODOに紐付いた情報(ex. TODOが持つコメントなど)を持つこともあるので構造体を定義する前にどのようなデータをレスポンスするのかは毎回整理することをお勧めします。
1-2. 構造体を定義
整理した結果必要なのはTODOのみということがわかりました。
次にTODOにどのようなフィールドを持たせるのかを考えていきます。
シンプルなTODOなので
- TODOのID
- TODOのタイトル
- TODOの内容
- TODOの作成日時
- TODOの更新日時
を持つフィールドのTODO構造体を定義すれば良さそうです。
定義すると以下のようになります
type Todo struct {
ID int
Title string
Content string
CreatedAt time.Time
UpdatedAt time.Time
}
構造体を使った変数がどのようなものになるのか、試しに簡単なデータを作って実際に見てみましょう。
package main
import (
"fmt"
"time"
)
type Todo struct {
ID int
Title string
Content string
CreatedAt time.Time
UpdatedAt time.Time
}
func main() {
todo1 := Todo{
ID: 1,
Title: "todo1 title",
Content: "todo1 content",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
todo2 := Todo{
ID: 2,
Title: "todo2 title",
Content: "todo2 content",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
fmt.Printf("%+v\n", todo1)
fmt.Printf("%+v\n", todo2)
}
$ go run main.go
{ID:1 Title:todo1 title Content:todo1 content CreatedAt:2025-03-01 13:49:04.11412 +0900 JST m=+0.000410335 UpdatedAt:2025-03-01 13:49:04.114121 +0900 JST m=+0.000410460}
{ID:2 Title:todo2 title Content:todo2 content CreatedAt:2025-03-01 13:49:04.114121 +0900 JST m=+0.000410543 UpdatedAt:2025-03-01 13:49:04.114121 +0900 JST m=+0.000410626}
このように構造体型の変数を作ることができ、フィールドに値が埋め込まれていることも確認できました。
2. 構造体をjsonに変換 (エンコード)
構造体が作れたのでこのTODO構造体をjsonに変換していきましょう。
まずはGoの標準パッケージであるencoding/json
パッケージで構造体を使用してjsonへ変換する方法を学びその後Echoの場合での変換方法を学んで書き換えを行なっていきます。
2-1. json.Marshal
関数でのエンコード
encoding/json
パッケージを用いての変換ではjson.Marshal
関数を使用します。
func Marshal(v any) ([]byte, error)
json.Marshal
関数は引数に構造体を渡すことで渡した構造体をjson形式に変換を行い結果をバイトのスライスとして返します(失敗したらエラーをreturnする)。
ちなみに関数の引数がany
型となっているので、どんな型でも引数に渡すことができます。
実際に使用方法を見ていきましょう。
package main
import (
"encoding/json"
"fmt"
"time"
)
type Todo struct {
ID int
Title string
Content string
CreatedAt time.Time
UpdatedAt time.Time
}
func main() {
todo1 := Todo{
ID: 1,
Title: "todo1 title",
Content: "todo1 content",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
jsonData, err := json.Marshal(todo1)
if err != nil {
fmt.Println(err)
return
}
// 文字列に変換して出力
fmt.Println(string(jsonData))
}
実行してみると
$ go run main.go
// 整形してます
{
"ID": 1,
"Title": "todo1 title",
"Content": "todo1 content",
"CreatedAt": "2025-03-01T14:53:16.427823+09:00",
"UpdatedAt": "2025-03-01T14:53:16.427823+09:00"
}
実行結果を見るときちんとjsonにエンコードができているのがわかります。
2-2. jsonタグの追加
出力されたjsonを見ると、Goの構造体のフィールド名(キャメルケース)で出力されています。
{
"ID": 1,
"Title": "todo1 title",
"Content": "todo1 content",
"CreatedAt": "2025-03-01T14:53:16.427823+09:00",
"UpdatedAt": "2025-03-01T14:53:16.427823+09:00"
}
jsonのキー名をキャメルケースにするかスネークケースにするかは賛否両論ありますが、今回作るAPIではjsonのキー名はスネークケースで統一していきたいので追加で設定を加えていきます。
Goの構造体にjsonタグを追加することでエンコード時のjsonのキー名をカスタマイズできます。
jsonタグを追加した構造体は以下です。
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
実際にキー名が変更されているか出力してみましょう
package main
import (
"encoding/json"
"fmt"
"time"
)
// jsonタグを追加
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func main() {
todo1 := Todo{
ID: 1,
Title: "todo1 title",
Content: "todo1 content",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
jsonData, err := json.Marshal(todo1)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(jsonData))
}
実行結果
$ go run main.go
{
"id": 1,
"title": "todo1 title",
"content": "todo1 content",
"created_at": "2025-03-07T16:32:49.593819+09:00",
"updated_at": "2025-03-07T16:32:49.593819+09:00"
}
無事指定したキー名で出力することができました。
2-3. Echoの場合でのエンコードの仕方(c.JSON
)
encoding/json
パッケージを使用する方法を紹介しましたが、Echoではより簡単にjsonをエンコードすることができます。
Echoのecho.Context
には、JSONレスポンスを簡単に返せるc.JSON
メソッドがあります。このメソッドを使えば、json.Marshal
を使わずに、構造体を直接JSONとして返すことができます。
func (c *Context) JSON(code int, i interface{}) error
c.JSON
メソッドでは内部でencoding/json
パッケージのエンコード処理を行うjson.NewEncoder
という関数を使用していて、実質json.Marshal
と同じような挙動をしています。
c.JSON
メソッドが便利な点としては、レスポンスの書き込みだけでなく本来別で実装する必要があるレスポンスヘッダーの設定やHTTPステータスコードの設定も内部で行なっているのでEchoを使う場合は基本的に c.JSON
を使用するのがベストプラクティスです。
c.JSON
の便利さがわかったところで実際に実装例を見ていきましょう。
package main
import (
"net/http"
"time"
"github.com/labstack/echo/v4"
)
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func getTodoHandler(c echo.Context) error {
todo := Todo{
ID: 1,
Title: "Sample Todo",
Content: "This is a sample task",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// JSONレスポンスとして返す
return c.JSON(http.StatusOK, todo)
}
func main() {
e := echo.New()
e.GET("/todos/:id", getTodoHandler)
e.Start(":8080")
}
$ curl -X GET http://localhost:8080/todos/1
{
"id": 1,
"title": "Sample Todo",
"content": "This is a sample task",
"created_at": "2025-03-07T16:32:49.593819+09:00",
"updated_at": "2025-03-07T16:32:49.593819+09:00"
}
引数にレスポンスするステータスコードとjsonに変換したい構造体を指定するだけでjsonレスポンスとして返すことができます。
3. jsonを構造体に変換 (デコード)
構造体をjsonに変換する方法について学んだので次はその逆のjsonを構造体に変換(デコード)する方法について学んでいきます。
こちらもエンコードの時と同様にまずはencoding/json
パッケージで提供されている方法を学びその後Echoで提供されているメソッドでの書き換えを行なっていきます。
3-1. json.UnMarshal
関数でのデコード
encoding/json
パッケージでは、json.Unmarshal
関数を使用することでJSONデータをGoの構造体に変換することができます。
func Unmarshal(data []byte, v any) error
この関数は、JSONデータであるバイトスライスとGoの構造体(v)の二つを引数に持ちます。
関数内では、引数として渡されたJSONデータのキーと構造体のフィールド名を比較し、一致するフィールドに対応する値を格納する処理を行ってます。その過程で失敗した場合はエラーを返すようになっています。
実際に使用例を見ていきましょう。
package main
import (
"encoding/json"
"fmt"
)
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
}
func main() {
// JSONデータ
jsonData := `{"id": 1, "title": "Sample Todo", "content": "This is a sample task"}`
var todo Todo
// JSONデコード(変換)
err := json.Unmarshal([]byte(jsonData), &todo)
if err != nil {
fmt.Println("Error:", err)
return
}
// デコード結果を表示
fmt.Printf("Decoded Struct: %+v\n", todo)
}
実行結果
$ go run main.go
Decoded Struct: {ID:1 Title:Sample Todo Content:This is a sample task}
このように、JSONデータをGoの構造体にデコードすることができました。
json.Unmarshal
の注意点
① デコード先の変数はポインタで渡す
json.Unmarshal
はJSONデータをデコードして構造体のフィールドに直接値をセットするためポインタで渡す必要があります。
var todo Todo
json.Unmarshal([]byte(jsonData), todo) // NG
json.Unmarshal([]byte(jsonData), &todo) // OK
このあたりの説明は過去にポインタの仕組みについて解説しているのでそちらを見ていただけるとよりわかりやすいかと思います。
② 不要なJSONキーは無視される
json.Unmarshal
ではJSONのキーと構造体のフィールド名は一致している必要があります。
構造体に定義されていないフィールドがJSONデータに含まれていても、エラーにはならず無視されます。
jsonData := `{"id": 1, "title": "Sample Todo", "content": "This is a sample task", "extra": "This field is ignored"}`
仮に上記のようなJSONデータをデコードした場合、extra
フィールドは構造体には存在しないためデコードしても無視されます。
3-2. Echoの場合でのデコードの仕方(c.Bind
)
続いてEchoの場合でのデコードの仕方を見ていきましょう。
EchoではリクエストのJSONデータを簡単にGoの構造体にデコードする方法としてc.Bind
メソッドが提供されています。
func (c *Context) Bind(i interface{}) error
c.Bind
メソッドでは内部でencoding/json
パッケージのデコード処理を行うjson.NewEncoder
という関数を使用していて、実質json.Unmarshal
と同じような挙動をしています。
c.Bind
が便利な点としては、リクエストの Content-Type
によって適切なデコーダーを選択してデコードを行なってくれます。
今回はJSONなのでContent-Type
はapplication/json
となりapplication/json
用のデコーダーであるjson.Unmarshal
(正確にはjson.NewEncoder
)が選択されデコードを行なっています。
それでは実際にc.Bind
の使用例を見ていきましょう。
今回はPOSTリクエストのリクエストボディをGoの構造体にデコードしてそのままレスポンスしています。
package main
import (
"net/http"
"github.com/labstack/echo/v4"
)
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
}
func createTodoHandler(c echo.Context) error {
var todo Todo
// JSONデータを構造体にバインド
if err := c.Bind(&todo); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid JSON format"})
}
// デコードされたデータをレスポンスとして返す
return c.JSON(http.StatusOK, todo)
}
func main() {
e := echo.New()
e.POST("/todos", createTodoHandler)
e.Start(":8080")
}
実際にサーバーを起動してリクエストを送ってみます。
$ curl -X POST -H "Content-Type: application/json" -d '{"id":1, "title":"Sample Todo", "content":"This is a sample task"}' http://localhost:8080/todos
{
"id": 1,
"title": "Sample Todo",
"content": "This is a sample task"
}
c.Bind
を利用すると簡単にリクエストボディのJSONをデコードできることがわかりました。
4. (実践演習) TODO APIに実装
これまで学んだ内容をTODO APIに実装していきましょう。
4-1. 事前準備
実際に実装に移る前に以下の事前準備を行なっていきます。
- データモデルの定義
- モックデータを用意
データモデルの定義
まずはデータモデル用のパッケージを作成してそこに先ほど作ったTodo構造体を定義していきます。
新しくmodelsディレクトリをその中にmodels.go
というファイルを作成して構造体を定義していきます。
モックデータを用意
データモデルが定義できたらモックデータを用意していきます。
本来のAPIではリクエストを受けたらデータベースからデータを取得してそれをレスポンスに入れる必要がありますが、現在時点ではデータベース周りの機能の実装は行っていないのでモックデータを用意する必要があります。
先ほど作成したmodelsディレクトリの中にmock_data.go
というモックデータ用のファイルを作成してそこにモックデータを定義しておきます。
4-2. 実装
事前準備が完了したので全てのエンドポイントに対して実装を行います。
各リクエストごとに簡単に処理の流れを書くのでそちらを参考にして実装してみてください。
- 各コミットごとに実装例を貼りましたが全体で見たい場合はこちら
POSTリクエスト
- リクエストボディからTodoデータをmodels.Todo構造体にバインドする
- 失敗した場合は500エラーをレスポンスする
- 成功した場合は、TodoデータをJSONレスポンスとして返すように
PUTリクエスト
- パスパラメータから更新対象のIDを取得
- IDが不正な場合は400エラーをレスポンスする
- リクエストボディからTodoデータをmodels.Todo構造体にバインドする処理を追加
- 失敗した場合は500エラーをレスポンスする
- バインドしたデータにURLパラメータから取得したIDを設定
- 更新されたTodoデータをJSONレスポンスとして返すように
GETリクエスト(全件取得)
- モックデータ(Todo1とTodo2)を配列に格納
- モックデータをJSONレスポンスとして返す
GETリクエスト(IDから取得)
- パスパラメータからIDを取得
- IDが不正な場合は400エラーをレスポンスする
- 取得したIDを暫定でログ出力
- モックデータをJSONレスポンスとして返却する
DELETEリクエスト
- パスパラメータから削除対象のIDを取得する
- IDが不正な場合は400エラーをレスポンスする
- 成功した場合は削除完了メッセージをJSONレスポンスとして返却する
最後に動作確認をしてみましょう。
動作確認用のcurlを書いておくので参考にしてみてください!
# POST
curl -X POST http://localhost:8080/todos \
-H "Content-Type: application/json" \
-d '{
"title": "新しいTODO",
"content": "これは新しいTODOのコンテンツです"
}'
# PUT
curl -X PUT http://localhost:8080/todos/1 \
-H "Content-Type: application/json" \
-d '{
"title": "更新されたTODO",
"content": "これは更新されたTODOのコンテンツです"
}'
# GET(全件取得)
curl -X GET http://localhost:8080/todos
# GET(1件取得)
curl -X GET http://localhost:8080/todos/1
# DELETE
curl -X DELETE http://localhost:8080/todos/1
2章まとめ
この章でで学んだことは以下です。
- Goの構造体について
- 構造体をjsonにエンコードする方法
-
json.Marshal
とEchoのc.JSON
の違い
-
- jsonを構造体にデコードする方法
-
json.Unmarshal
とEchoのc.Bind
の違い
-
3章 DBとの接続
TODOのデータの保存や取得をするにはTODO APIとDBを接続する必要があります。
Goの標準パッケージにはdatabase/sql
がありますが今回はdatabase/sql
をラップしたライブラリであるsqlxを使用していきます。
sqlx
はdatabase/sql
と比べて構造体へのマッピングやクエリの実行、ポインタでNull値を扱えるなど直感的かつ簡潔に書けることが多いので筆者もよく使用しています。
1. DBのセットアップ
1-1. DBの用意
今回使用するDBはMySQLを使用します。MySQLの環境は、ローカルに直接インストールする方法や、Dockerコンテナを使用する方法がありますが、今回はDockerコンテナを使用した方法を紹介します。
docker-compose.ymlを用意しMySQLを立ち上げる
MySQLを実行するための docker-compose.yml
を作成します。TODO APIを作成しているディレクトリにdocker-compose.yml
と環境変数を定義する.env
ファイル、.env
の環境変数をgitで管理したくないので.gitignore
ファイルを作成し各ファイルに諸々記入していきます。
docker-compose.yml
version: '3.8'
services:
mysql:
image: mysql:5.7
container_name: go-todo-db
environment:
MYSQL_ROOT_USER: ${MYSQL_ROOT_USER}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
command: ["mysqld", "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
ports:
- "${DB_PORT}:3306"
volumes:
- mysql_data:/var/lib/mysql
networks:
- mysql_network
volumes:
mysql_data:
networks:
mysql_network:
今回MySQLのバージョンは5.7を使用しています。
.env
MYSQL_ROOT_USER=root
MYSQL_ROOT_PASSWORD=rootpassword
MYSQL_DATABASE=testdb
MYSQL_USER=docker
MYSQL_PASSWORD=password
DB_PORT=3306
.gitignore
.env
今回はGoのAPIについての解説をメインにしているのでこの辺りの細かい解説は省きます。
コンテナを立ち上げて無事立ち上がればDBの用意はOKです
$ docker-compose up
go-todo-db | 2025-03-14T09:37:36.427734Z 0 [Note] Event Scheduler: Loaded 0 events
go-todo-db | 2025-03-14T09:37:36.428648Z 0 [Note] mysqld: ready for connections.
go-todo-db | Version: '5.7.44' socket: '/var/run/mysqld/mysqld.sock' port: 3306 MySQL Community Server (GPL)
1-2. テーブル作成と初期データの投入
DBの立ち上げができたら今回使用するテーブルと初期データの投入を行なっていきます。
今回はSQLスクリプトではなくSequel Aceを使用してデータを投入していきます。
Sequel Aceのインストールについては以下を参考にしてください
Sequel AceがインストールできたらSequel Aceを立ち上げ以下の接続情報を入力して接続します
ホスト名: 127.0.0.1
ユーザー名: docker
パスワード: password
データベース: testdb
ポート: 3306
接続できたらクエリを流していきます。
- テーブル作成
CREATE TABLE IF NOT EXISTS todos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
- 初期データの登録
INSERT INTO todos (title, content, created_at, updated_at)
VALUES
('todo1 title', 'todo1 content', NOW(), NOW()),
('todo2 title', 'todo2 content', NOW(), NOW()),
('todo3 title', 'todo3 content', NOW(), NOW());
初期データの登録ができたか実際にSELECTして確認してみます。
SELECT * FROM todos;
投入したデータが取得できていたらOKです。
+----+--------------+---------------+---------------------+---------------------+
| ID | title | content | created_at | updated_at |
+----+--------------+---------------+---------------------+---------------------+
| 1 | todo1 title | todo1 content | 2024-03-14 12:00:00 | 2024-03-14 12:00:00 |
| 2 | todo2 title | todo2 content | 2024-03-14 12:00:00 | 2024-03-14 12:00:00 |
| 3 | todo3 title | todo3 content | 2024-03-14 12:00:00 | 2024-03-14 12:00:00 |
+----+--------------+---------------+---------------------+---------------------+
2. sqlx
を使用してDBに接続
DBの用意ができたのでDBに接続するための処理を書いていきましょう。
冒頭にあるように今回はsqlx
を使用し、MySQLデータベースに接続する方法を解説します。
2-1. sqlx
とドライバのインストール
まずは以下を実行してsqlx
をインストールします。
$ go get -u github.com/jmoiron/sqlx
Goの標準パッケージであるdatabase/sql
パッケージや今回使用するsqlx
ではデータベースとの接続を抽象化したインターフェースを提供しますが、実際にMySQLに接続するためには、対応するドライバを別途インストールする必要があります。
ドライバとは、データベースとアプリケーションの間で通信を行うためのソフトウェアコンポーネントのことです。
なぜドライバが必要かというと、MySQLやPostgreSQLのようにDBごとに異なる通信プロトコルや接続方法あるので、database/sql
やsqlx
だけでは対応できません。
なので各DBに対応したドライバを別途インストールすることで、Goのコードは database/sql
やsqlx
を通じて、異なるデータベースに柔軟に接続することができます。
今回のDBはMySQLを使用しているので、 github.com/go-sql-driver/mysql
というGoでMySQLを扱う際に最も一般的に使用されてるドライバを使用します。
以下を実行してドライバのインストールを行います。
$ go get -u github.com/go-sql-driver/mysql
2-2. データベース接続処理の実装
ドライバのインストールができたので実際にDBと接続するための実装を行なっていきます。
今回の接続はmain.go
で行いたいので一旦既存の実装はコメントアウトしたうえで実装していきます。
package main
import (
"fmt"
"log"
"github.com/jmoiron/sqlx"
_ "github.com/go-sql-driver/mysql"
)
const (
dbUser = "docker"
dbPassword = "password"
dbHost = "127.0.0.1"
dbPort = "3306"
dbName = "testdb"
)
func main() {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true",dbUser, dbPassword, dbHost, dbPort, dbName)
db, err := sqlx.Connect("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
fmt.Println("Successfully connected to the database")
}
簡単に解説をしていきます。
- 接続情報の定義
const (
dbUser = "docker"
dbPassword = "password"
dbHost = "127.0.0.1"
dbPort = "3306"
dbName = "testdb"
)
func main() {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true",dbUser, dbPassword, dbHost, dbPort, dbName)
}
.env
の環境変数として定義したDBへの接続情報を定数として定義しそれらをまとめた変数dsn
を定義しています。
- データベース接続と疎通確認
db, err := sqlx.Connect("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
fmt.Println("Successfully connected to the database")
sqlx
にはConnect
関数が存在します。
func Connect(driverName, dataSourceName string) (*DB, error)
この関数では第1引数に先ほど説明したどのDBのドライバを使用するのかを指定できます。
今回はMySQLのドライバを使用するため"mysql"
を引数に渡しています。
そして第2引数にデータベースのアドレスを指定することで、そのデータベースに接続すること
ができます。
返り値となっている sqlx.DB
型というのは、そのデータベースへのコネクションを管理するた
めの構造体で、今後このデータベースに対してクエリ等何かを実行したい場合には、全てこの
sqlx.DB
型を経由して行うことになります。
defer
で指定しているClose
関数は、プログラム実行後DBを使わない段階の場合にはDBへのコネクションを閉じる必要があるためsqlx.DB
型のClose
をdefer
で呼ぶことで実現しています。
ちなみにこのConnect
関数は標準パッケージであるdatabase/sql
には存在しない関数です。
database/sql
でデータベース接続と疎通確認するには以下のように、
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatal(err)
}
fmt.Println("Successfully connected to the database")
Open
関数とPing
関数を組み合わせて行う必要がありますが、sqlx
のConnect
関数ではその二つの関数を内部で実行しているのでConnect
関数のみで接続と疎通確認ができるようになっています。
MySQLのコンテナを立ち上げ、今回実装したものを実際に実行してみましょう。
実際に以下のようなメッセージが出力されれば接続は成功しています。
$ go run main.go
Successfully connected to the database
3. データの取得と挿入処理
DBと接続できるようになったので、データの取得(SELECT
)とデータの挿入(INSERT
)を行う処理を実装していきます。
3-1. データの取得(SELECT
)
まずはデータ取得からです。
Todoの全件取得を実装してみます。
func getTodos(db *sqlx.DB) ([]models.Todo, error) {
dbTodos := []struct {
ID int `db:"id"`
Title string `db:"title"`
Content string `db:"content"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
query := "SELECT id, title, content, created_at, updated_at FROM todos"
err := db.Select(&dbTodos, query)
if err != nil {
return nil, err
}
todos := make([]models.Todo, 0)
for _, dbTodo := range dbTodos {
todos = append(todos, models.Todo{
ID: dbTodo.ID,
Title: dbTodo.Title,
Content: dbTodo.Content,
CreatedAt: dbTodo.CreatedAt,
UpdatedAt: dbTodo.UpdatedAt,
})
}
return todos, nil
}
簡単に解説していきます。
- 構造体のスライスを定義
dbTodos := []struct {
ID int `db:"id"`
Title string `db:"title"`
Content string `db:"content"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
ここではこの後行うクエリの実行の結果を一時的に格納する構造体のスライスを定義しています。
各フィールドにdbタグをつけることでDBのカラム名とGoの構造体のフィールドをマッピングすることができます。
ちなみに、CreatedAtとUpdatedAtで使用しているtime.Time
型は日付と時刻を扱うためのGoの標準型で、parseTime=true
パラメータをDSNに設定しているため、MySQLのDATETIME
型からGoのtime.Time
型への自動変換が可能になっています。
- クエリの実行
query := "SELECT id, title, content, created_at, updated_at FROM todos"
err := db.Select(&dbTodos, query)
if err != nil {
return nil, err
}
sqlxが提供するSelect
関数を使用してクエリの実行を行っています。
func (db *DB) Select(dest interface{}, query string, args ...interface{}) error
Select
関数の第1引数には、結果を格納するスライスへのポインタを指定し、第2引数に実行するクエリの文字列を指定します。
今回は指定していませんが、関数の定義を見ると第3引数にクエリのパラメータも指定することができます。これは今後の実装で使用するのでぜひ覚えておいて欲しいです。
db.Select(&todos, "SELECT * FROM todos WHERE id = ?", id)
ちなみにSelect
関数を使用せずにdatabase/sql
だけで実装を行うと以下のようになります。
func getTodos(db *sql.DB) ([]models.Todo, error) {
query := "SELECT id, title, content, created_at, updated_at FROM todos"
// クエリを実行して結果セット(Rows)を取得
rows, err := db.Query(query)
if err != nil {
return nil, err
}
// クローズ
defer rows.Close()
// 結果を格納するスライスを初期化
todos := []models.Todo{}
// 結果セットの各行を処理
for rows.Next() {
// 一時変数を宣言
var id int
var title, content string
var createdAt, updatedAt time.Time
// 現在の行の各カラムの値を変数にスキャン
err := rows.Scan(&id, &title, &content, &createdAt, &updatedAt)
if err != nil {
return nil, err
}
// 取得した値からTodo構造体を作成
todo := models.Todo{
ID: id,
Title: title,
Content: content,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
// 結果スライスに追加
todos = append(todos, todo)
}
return todos, nil
}
コメントにも書きましたがポイントとなる処理の流れを簡単に整理すると、
-
Query
関数でクエリを実行し結果を取得 - 実行結果を
Next
関数を使用して結果の各行を順番に処理 -
Scan
関数を使用して行データのスキャン
を行なっています。
Select
関数では上記の処理を内包しているのでコードを簡潔に書くことができます。
- 実行結果を
models.Todo
の形式に変換
todos := make([]models.Todo, 0)
for _, dbTodo := range dbTodos {
todos = append(todos, models.Todo{
ID: dbTodo.ID,
Title: dbTodo.Title,
Content: dbTodo.Content,
CreatedAt: dbTodo.CreatedAt,
UpdatedAt: dbTodo.UpdatedAt,
})
}
return todos, nil
最後にクエリ実行後DBから取得したデータをアプリケーションで使用するmodels.Todo
型の形式に変換しています。
ちなみになぜこのようなことを行なっているかというと、データベースの構造(テーブル設計)とアプリケーションのドメインモデル(models.Todo
)を分離することで、仮にデータベーススキーマが変更されてもこの変換ロジックだけを修正すれば、アプリケーション全体に影響を与えないため変更に強くなるためです。
この辺りは過去にクリーンアーキテクチャについて解説した記事にも書いたのでそちらを参照して貰えばと思います。
3-2. データの挿入(INSERT
)
続いてデータの挿入(INSERT
)を実装していきます。
func createTodo(db *sqlx.DB, todo models.Todo) (models.Todo, error) {
now := time.Now()
params := map[string]interface{}{
"title": todo.Title,
"content": todo.Content,
"created_at": now,
"updated_at": now,
}
query := `
INSERT INTO todos (title, content, created_at, updated_at)
VALUES (:title, :content, :created_at, :updated_at)
`
result, err := db.NamedExec(query, params)
if err != nil {
return models.Todo{}, err
}
id, err := result.LastInsertId()
if err != nil {
return models.Todo{}, err
}
todo.ID = int(id)
return todo, nil
}
こちらも簡単に処理の解説をしていきます。
- パラメータマップを作成
params := map[string]interface{}{
"title": todo.Title,
"content": todo.Content,
"created_at": now,
"updated_at": now,
}
マップを作成せず引数のmodels.Todo
をそのままクエリ実行の関数に渡しても良いのですが、各パラメータと明示的なマッピングを行いたい、created_at
とupdated_at
に現在時刻を設定したかったためマップを作成しています。
- クエリの実行
query := `
INSERT INTO todos (title, content, created_at, updated_at)
VALUES (:title, :content, :created_at, :updated_at)
`
result, err := db.NamedExec(query, params)
if err != nil {
return models.Todo{}, err
}
クエリの実行にはNamedExec
関数を使用しています。
NamedExec
関数は第1引数に指定する名前付きパラメータと構造体やマップのフィールド・キーをマッピングして、SQLを実行する関数です。
func (db *DB) NamedExec(query string, arg interface{}) (sql.Result, error)
名前付きパラメータというのは、クエリの中で :title
や :content
のように名前をつけて指定するパラメータのことで、名前をつけて指定することで第2引数に渡す構造体やマップと同じ名前でマッピングすることができます。
名前付きパラメータの利点としては、通常の database/sql
や sqlx.Exec
を使う場合、プレースホルダは ?
を使います
db.Exec("INSERT INTO todos (title, content) VALUES (?, ?)", "タイトル", "内容")
このような指定の仕方は値の順番に依存するため、引数の順序ミスが起きやすいためこれを防ぐことができます。
NamedExec
関数の返り値はsql.Result
型となっています。
sql.Result
型は、SQLの実行結果に関する情報を取得するためのインターフェースです。
この後の処理に出てくるLastInsertId
もsql.Result
が提供するメソッドです。
つまりsql.Result
を使うことで、実際にデータベースで何が起きたのかを確認できるようになります。
- 挿入されたIDの取得
id, err := result.LastInsertId()
if err != nil {
return models.Todo{}, err
}
INSERTした結果のIDをLastInsertId
関数を使用して取得しています。
なぜIDを取得する必要があるかというと、今回テーブルを作成した時に主キーであるIDにAUTO_INCREMENT
を指定しています。
CREATE TABLE IF NOT EXISTS todos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
AUTO_INCREMENT
を指定した場合、新しい行がINSERTされると、データベースが自動的に一意のID値を生成します。
つまりこのIDはアプリケーションが指定するのではなく、データベースが決定していることになり挿入された行を後で参照するためにはIDを取得して知る必要があるためです。
4. (実践演習) repositoryの実装
これまでの内容を含めてTODO APIのDBアクセス周りの実装していきましょう。
その前に新しくrepositories
というディレクトリを作成しその中にtodos.go
というファイルを作成します。
repositoryとはざっくりいうとDBへのアクセス処理をビジネスロジックから切り離すためにそれらの処理を集約する役割をします。
この辺りについても先ほど貼ったクリーンアーキテクチャについて解説している記事があるので詳しくはそちらを参照して欲しいです。
それではrepositories/todos.go
に全ての処理を実装してみてください。
基本的には今回の内容だけで全て実装できるかと思います。
3章まとめ
この章で学んだことは以下です。
- sqlx を使ってGoからDBに接続できる
- sqlx を使ってデータの取得と挿入ができる
ここまでで前編は終了です。
後編へ続く!