0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Go初心者のためのTODO APIハンズオン 前編

Last updated at Posted at 2025-05-08

はじめに

弊社ではバックエンドにGo言語を採用しています。
ここ最近自チームにGo言語未経験で入社したメンバーがいたのでそんなメンバーに向けてGoの基本的な文法を学んだ次のステップとして簡単なAPIを作成するハンズオンの教材を書きました。
対象読者としてはA Tour of Goなどをやり基本的な文法は知っているが、動くものは一度も作ったことがないという人を対象にしています。

使用技術

ソースコード

作成する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 のハンドラ関数では、

  1. req *http.Request からリクエストの情報を取得し、レスポンスの内容を決める
  2. w http.ResponseWriter にレスポンスの内容を書き込む

という手順でレスポンスを返します。

ただ、今回のhelloHandler 関数では、リクエストの内容に関係なく、常に "Hello, World!" を返すシンプルな実装になっているため、1 は省略されています。

2について、レスポンスの書き込みには io.WriteString 関数を使用しています。

// io.WriteString 関数の実際の定義
func WriteString(w Writer, s string) (n int, err error)

io.WriteString 関数を見ていくと、この関数は、第一引数 wio.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 の第一引数 wio.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 として扱うことができる」という意味です。

例えば、以下のような構造体Struct1Write メソッドを持っているため、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 メソッドを持たない構造体 Struct2io.Writer 型として扱うことができません。

type Struct2 struct{}
var w io.Writer
w = Struct2{} // コンパイルエラー

今回の場合だとhttp.ResponseWriterWrite(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メソッドだけ受け付けるように実装していきましょう。

実装方針としては以下です。

  1. リクエストの内容からどのHTTPメソッドかを判断
  2. 定義したHTTPメソッドだった場合は正常応答を返却
  3. 定義した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.MethodGetnet/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を削除

実装方針としては以下です。

  1. 各エンドポイントのハンドラを作成
  2. 任意のHTTPメソッドのみを許可するように
  3. 許可したHTTPメソッドでない場合は405エラーを返却するように
  4. エンドポイント/todos/:id のパスパラメータに関してはここでは一旦適当なIDを指定しておく
    1. パスパラメータの扱いに関しては後ほど解説します

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.ResponseWriterreq *http.Requestからc echo.Contextになっています。

元の実装:

func helloHandler(w http.ResponseWriter, req *http.Request) {
	io.WriteString(w, "Hello, world!")
}

echo.Context とは一言で言うとリクエストとレスポンスの情報をまとめて管理しているコンテキストでhttp.ResponseWriterhttp.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

このあたりの説明は過去にポインタの仕組みについて解説しているのでそちらを見ていただけるとよりわかりやすいかと思います。

Goのポインタについて改めて理解する

② 不要な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-Typeapplication/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. 事前準備

実際に実装に移る前に以下の事前準備を行なっていきます。

  1. データモデルの定義
  2. モックデータを用意

データモデルの定義

まずはデータモデル用のパッケージを作成してそこに先ほど作ったTodo構造体を定義していきます。

新しくmodelsディレクトリをその中にmodels.goというファイルを作成して構造体を定義していきます。

モックデータを用意

データモデルが定義できたらモックデータを用意していきます。

本来のAPIではリクエストを受けたらデータベースからデータを取得してそれをレスポンスに入れる必要がありますが、現在時点ではデータベース周りの機能の実装は行っていないのでモックデータを用意する必要があります。

先ほど作成したmodelsディレクトリの中にmock_data.goというモックデータ用のファイルを作成してそこにモックデータを定義しておきます。

4-2. 実装

事前準備が完了したので全てのエンドポイントに対して実装を行います。

各リクエストごとに簡単に処理の流れを書くのでそちらを参考にして実装してみてください。

  • 各コミットごとに実装例を貼りましたが全体で見たい場合はこちら

POSTリクエスト

  1. リクエストボディからTodoデータをmodels.Todo構造体にバインドする
    1. 失敗した場合は500エラーをレスポンスする
  2. 成功した場合は、TodoデータをJSONレスポンスとして返すように

PUTリクエスト

  1. パスパラメータから更新対象のIDを取得
    1. IDが不正な場合は400エラーをレスポンスする
  2. リクエストボディからTodoデータをmodels.Todo構造体にバインドする処理を追加
    1. 失敗した場合は500エラーをレスポンスする
  3. バインドしたデータにURLパラメータから取得したIDを設定
  4. 更新されたTodoデータをJSONレスポンスとして返すように

GETリクエスト(全件取得)

  1. モックデータ(Todo1とTodo2)を配列に格納
  2. モックデータをJSONレスポンスとして返す

GETリクエスト(IDから取得)

  1. パスパラメータからIDを取得
    1. IDが不正な場合は400エラーをレスポンスする
  2. 取得したIDを暫定でログ出力
  3. モックデータをJSONレスポンスとして返却する

DELETEリクエスト

  1. パスパラメータから削除対象のIDを取得する
    1. IDが不正な場合は400エラーをレスポンスする
  2. 成功した場合は削除完了メッセージを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を使用していきます。

sqlxdatabase/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/sqlsqlxだけでは対応できません。

なので各DBに対応したドライバを別途インストールすることで、Goのコードは database/sqlsqlxを通じて、異なるデータベースに柔軟に接続することができます。

今回の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型のClosedeferで呼ぶことで実現しています。

ちなみにこの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関数を組み合わせて行う必要がありますが、sqlxConnect関数ではその二つの関数を内部で実行しているので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
}

コメントにも書きましたがポイントとなる処理の流れを簡単に整理すると、

  1. Query関数でクエリを実行し結果を取得
  2. 実行結果をNext関数を使用して結果の各行を順番に処理
  3. 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_atupdated_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/sqlsqlx.Exec を使う場合、プレースホルダは ? を使います

db.Exec("INSERT INTO todos (title, content) VALUES (?, ?)", "タイトル", "内容")

このような指定の仕方は値の順番に依存するため、引数の順序ミスが起きやすいためこれを防ぐことができます。

NamedExec関数の返り値はsql.Result 型となっています。

sql.Result 型は、SQLの実行結果に関する情報を取得するためのインターフェースです。

この後の処理に出てくるLastInsertIdsql.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 を使ってデータの取得と挿入ができる

ここまでで前編は終了です。

後編へ続く!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?