4章 ユニットテスト
3章でrepositoryパッケージを作りデータベースへのアクセス処理を実装しましたが、ハンドラ層からrepositoryパッケージを呼び出す処理をまだ実装していないためそれらが正しく動くかどうかを確かめられていません。
ハンドラ層からの呼び出し処理を実装して動作確認を行なっても良いのですが、それだとハンドラ層と合わせた動作確認になってしまい何か問題があった場合にハンドラが悪いのかrepositoryが悪いかの切り分けから始める必要があります。
そのためrepositoryパッケージのみの挙動に焦点を絞って動作確認を行うためにこの章ではrepository単独での挙動確認(ユニットテスト)を実装していきたいと思います。
1. ユニットテストの基本
1-1. テストファイルの作成
まずは簡単なユニットテストを書きながらユニットテストの基本について解説していきます。
repositoriesディレクトリに新しくtodos_test.go
というファイルを作成します。
Go ではファイル名が xxx_test.go
となっているファイルはテストが書かれたファイルと認識されるので、この命名にしています。
1-2. ユニットテストの実装
それでは例としてGetTodo
関数のテストを実装していきたいと思います。
実装方針としては、以下のような手順でGetTodo
関数のテストを実装していこうと思います。
- 既にデータベースに入っているTODOデータを1つ選ぶ
- 1 で選んだTODOを
GetTodo
関数でDBから取得する - 2 での取得結果が、元のTODOデータの値と一致するかどうかを確かめる
package repositories
import (
"fmt"
"go-todo-app/models"
"log"
"testing"
_ "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
)
func TestGetTodo(t *testing.T) {
dbUser := "docker"
dbPassword := "password"
dbHost := "127.0.0.1"
dbPort := "3306"
dbName := "testdb"
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()
expected := models.Todo{
ID: 1,
Title: "todo1 title",
Content: "todo1 content",
}
got, err := GetTodo(db, expected.ID)
if err != nil {
t.Fatalf("failed to get todo: %v", err)
}
if got.ID != expected.ID {
t.Errorf("got %v, expected %v", got.ID, expected.ID)
}
if got.Title != expected.Title {
t.Errorf("got %v, expected %v", got.Title, expected.Title)
}
if got.Content != expected.Content {
t.Errorf("got %v, expected %v", got.Content, expected.Content)
}
}
簡単に解説していきます。
- 関数定義
func TestGetTodo(t *testing.T)
Goのテストフレームワークに認識されるには、関数名がTest
で始まり、*testing.T
型の引数を受け取る必要があるのでGoでテストを行う場合はこの形式になるように関数定義する必要があります。
- データベース接続の設定
dbUser := "docker"
dbPassword := "password"
dbHost := "127.0.0.1"
dbPort := "3306"
dbName := "testdb"
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()
repositoryのテストではDBアクセスが必要なので、3章で実装した処理をそのまま持ってきてDBアクセスのための設定と実際にDBアクセスを行う処理を書いています
- 期待値の設定
expected := models.Todo{
ID: 1,
Title: "todo1 title",
Content: "todo1 content",
}
ここが実装方針の1に書いた既にデータベースに入っているTODOデータを1つ選ぶ部分です。
データベースから取得されるべきTodoアイテムの期待値を設定しています。
- テスト対象関数の実行
got, err := GetTodo(db, expected.ID)
if err != nil {
t.Fatalf("failed to get todo: %v", err)
}
今回テストしたいGetTodo
関数を実際に実行しています。
実行に失敗した場合はt.Fatal
メソッドを使用してテストを失敗させています。
テスト関数の引数として与えられた*testing.T
には、Fatal
メソッドとFatalf
メソッドが存
在しています。
この2つのメソッドの違いとしてはfmt.Println
関数のような出力指定かfmt.Printf
関数の出力指定を行うかという点で他は同じ機能を持ちます。
t.Fatal
系のメソッドが実行されるとテストが失敗されるのとそれ以降の処理の実行が行われなくなるため、DB接続による失敗などこの処理に失敗した場合、テストが成り立たないというという状態になった時に使用します。
- 結果の検証
if got.ID != expected.ID {
t.Errorf("got %v, expected %v", got.ID, expected.ID)
}
if got.Title != expected.Title {
t.Errorf("got %v, expected %v", got.Title, expected.Title)
}
if got.Content != expected.Content {
t.Errorf("got %v, expected %v", got.Content, expected.Content)
}
GetTodo
関数で取得した結果が最初に定義したexpected
の内容と同じかどうかを検証しています。
不一致だった場合はt.Error
でテストを失敗させています。
*testing.T
には、 先ほど紹介したFatal
系のメソッド以外にもテストを失敗させるためのメソッドとして、Error
メソッドとErrorf
メソッドが存在します。
Error
メソッドと Errorf
メソッドの違いもFatal
系メソッドと同様にfmt.Println
関数のような出力指定かfmt.Printf
関数の出力指定を行うかという点で他は同じ機能を持っています。
t.Fatal
系とt.Error
系メソッドの違いとしてはt.Error
はテストは失敗しますがその後の処理は継続して行われることです。
基本はt.Fatal
系の使用用途に当てはまらない場合はt.Error
系を使用します。
ex. 構造体の複数のフィールドの検証を行いたい時、複数の条件を検証したい時など
それでは実際にテストを実行していきたいと思います。
$ cd repositories
$ go test
PASS
ok go-todo-app/repositories 1.641s
テストが通っていればOKです。
2. テーブルドリブンテスト
ユニットテストの基本的な書き方がわかったのでこれからどんどんテストケースを増やしていきたいと思います。
その際にテーブルテストドリブンという仕組みを使用して実装していきたいと思います。
2-1. このままテストケースを増やした場合
現在のGetTodo
関数でテストできるのは、「ID1番のTODOが正しく取得できる」という部分だけです。このままID2番、ID3番とどんどんテストケースを増やしていく場合、愚直に実装したら以下のようになるかと思います。
// ID1番の取得を検証するテスト
func TestGetTodo(t *testing.T) {
// DBの接続処理
expected := models.Todo{
ID: 1,
Title: "todo1 title",
Content: "todo1 content",
}
got, err := GetTodo(db, expected.ID)
if err != nil {
t.Fatalf("failed to get todo: %v", err)
}
if got.ID != expected.ID {
t.Errorf("got %v, expected %v", got.ID, expected.ID)
}
if got.Title != expected.Title {
t.Errorf("got %v, expected %v", got.Title, expected.Title)
}
if got.Content != expected.Content {
t.Errorf("got %v, expected %v", got.Content, expected.Content)
}
}
// ID2番の取得を検証するテスト
func TestGetTodo2(t *testing.T) {
// DBの接続処理
expected := models.Todo{
ID: 2,
Title: "todo2 title",
Content: "todo2 content",
}
got, err := GetTodo(db, expected.ID)
if err != nil {
t.Fatalf("failed to get todo: %v", err)
}
if got.ID != expected.ID {
t.Errorf("got %v, expected %v", got.ID, expected.ID)
}
if got.Title != expected.Title {
t.Errorf("got %v, expected %v", got.Title, expected.Title)
}
if got.Content != expected.Content {
t.Errorf("got %v, expected %v", got.Content, expected.Content)
}
}
見てわかるようにexpected
以外は同様の処理になっているのがわかります。仮に100件分テストしたい場合にこれを100回繰り返すのは良くないです。
期待する値だけが異なるテストをfor文を回して重複を排除するというのがテーブルドリブンの発想です。
2-2. テーブルドリブンテストの実装
テーブルドリブンの仕組みを使用して先ほどのID1番と2番のTODOのデータをGetTodo
関数で取得できるかのテストを実装すると以下のようになります。
func TestGetTodo(t *testing.T) {
// DBの接続処理
testCases := []struct {
name string
expected models.Todo
}{
{
name: "test1",
expected: models.Todo{
ID: 1,
Title: "todo1 title",
Content: "todo1 content",
},
},
{
name: "test2",
expected: models.Todo{
ID: 2,
Title: "todo2 title",
Content: "todo2 content",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := GetTodo(db, tc.expected.ID)
if err != nil {
t.Fatal(err)
}
if got.ID != tc.expected.ID {
t.Errorf("ID: got %v, expected %v", got.ID, tc.expected.ID)
}
if got.Title != tc.expected.Title {
t.Errorf("Title: got %v, expected %v", got.Title, tc.expected.Title)
}
if got.Content != tc.expected.Content {
t.Errorf("Content: got %v, expected %v", got.Content, tc.expected.Content)
}
})
}
}
テーブルドリブンテストは、主に以下のような流れとなります。
- 「テストケース名」と「テストデータ」セットのスライスを作成
- 1 で作ったものを for文で回して2の中でサブテストを実施
1.「テストケース名」と「テストデータ」セットのスライスを作成
for
文を利用するために、テストケースを構造体のスライスでまとめておきます。
今回はテストのタイトルと期待する値をまとめておく構造体を定義してそのスライスを作成しています。
testCases := []struct {
name string
expected models.Todo
}{
{
name: "test1",
expected: models.Todo{
ID: 1,
Title: "todo1 title",
Content: "todo1 content",
},
},
{
name: "test2",
expected: models.Todo{
ID: 2,
Title: "todo2 title",
Content: "todo2 content",
},
},
}
2. 1 で作ったものを for文で回して2の中でサブテストを実施
for 文の中で、テストケースを使ったテストを記述します。
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := GetTodo(db, tc.expected.ID)
if err != nil {
t.Fatal(err)
}
if got.ID != tc.expected.ID {
t.Errorf("ID: got %v, expected %v", got.ID, tc.expected.ID)
}
if got.Title != tc.expected.Title {
t.Errorf("Title: got %v, expected %v", got.Title, tc.expected.Title)
}
if got.Content != tc.expected.Content {
t.Errorf("Content: got %v, expected %v", got.Content, tc.expected.Content)
}
})
}
全体の大枠としては、
func TestGetTodo(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// 個別のテスト
}
}
}
となっていて、あるテスト関数の中でさらに定義された子テストのことをサブテストと呼びます。
Goでサブテストを書くにはtesting.T
構造体のRun
メソッドを使用します。
func (t *T) Run(name string, f func(t *T)) bool
Run
メソッドの第1引数にはサブテスト名、第2引数にはサブテストの内容を指定します。
それでは実際にテストを実行してみましょう。
$ go test
PASS
ok go-todo-app/repositories 3.895s
テストが通っていればOKです。
3. リファクタリング
基本的にはこれまでの内容で全てのrepositoriesの関数のテストを実装することができますが、全ての関数内で毎回DBへの接続の処理を行う必要があるのでその部分だけ別の関数に切り出したいと思います。
repositoriesのディレクトリに新しくconnect_test.go
というファイルを作成します。
このファイル内にDB接続の処理を以下のように切り出します。
package repositories
import (
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
)
const (
dbUser = "docker"
dbPassword = "password"
dbHost = "127.0.0.1"
dbPort = "3307"
dbName = "testdb"
)
func connectDB() (db *sqlx.DB, err error) {
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)
}
return
}
あとは呼び出し側でこのconnectDB
関数を呼べば毎回DBへの接続の処理を書く必要がなくなります。
func TestGetTodo(t *testing.T) {
db, err := connectDB()
if err != nil {
t.Fatal(err)
}
defer db.Close()
// テストケースの実装
}
4. (実践演習)repositories パッケージのテスト
これまでの内容でrepositoriesパッケージ内に定義された全ての関数のユニットテストを実装していきましょう。
実装例を載せておきますがいきなり実装例を見るのではなく最低限実装方針を考えてから見ることをお勧めします。
実装例:https://github.com/aaaasahi/go-todo-app/blob/feature/4_unit_test/repositories/todos_test.go
まとめ
4章で学んだことは以下です。
-
testing
パッケージを使った基本的なユニットテストを実装できる - テーブルドリブンテストを活用できる
- repositoriesパッケージのユニットテストが実装できる
5章 サービス層の作成
現在の段階で実装したのは以下です。
- 受信した HTTP リクエストに対して、適切なレスポンスを作成して返すハンドラ層
- DBと接続しデータの取得、挿入、更新、削除を行うリポジトリ層
TODO APIを完成させるには2つの層を連携させる、具体的にはハンドラ層の中からレスポンスを作成するのに必要なリポジトリ層の処理を呼び出す必要があります。
このような、リポジトリ層から得たデータをハンドラ層が必要としている形に加工して、 2 つの
層の間を埋める部分のことをサービス層といいます。
5章ではこのサービス層を実装してTODO APIを完成させたいと思います。
1. サービス層の実装
サービス層を実装する前にまずはサービス層にはどのような機能が必要なのかを確かめていきましょう。
プロジェクト直下に新しくservicesディレクトリを作成しその中にtodo_service.go
というファイルを作成します。作成したtodo_service.go
のファイルの中にハンドラ層がTodo構造体関連で呼び出したい処理を書いていきます。
1-1. ハンドラ層で必要になる処理の洗い出し
まずはサービス層を使うことになるハンドラ層ではどのような処理が必要なのかをGetTodoHandler
を例に洗い出しを行っていきます。
現在のGetTodoHandler
の処理
func GetTodoHandler(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.String(http.StatusBadRequest, "Invalid ID format")
}
// 暫定でログを出力
log.Println(id)
return c.JSON(http.StatusOK, models.Todo1)
}
このハンドラの中で行う処理は以下の3ステップになります。
- パラメータからTODO IDを取得
- 指定IDのTODOをDBから取得する
- DBから取得した結果をレスポンスに書き込む
1と3に関しては既に実装済みなのでサービス層として2の「指定IDのTODOをDBから取得する」を実装する必要がありそうです。
1-2. 実装
GetTodoHandler
が必要としている「指定IDのTODOをDBから取得する」を実際に実装していきたいと思います。
まずは、関数の名前・引数・戻り値から考えていきます。
- 関数名:TODOデータを取得する機能なので、GetTodoService関数とすることに
- 引数:取得するTODOのIDが指定されるので、それをintで受け取る
- 戻り値:TODOが取得できたら models.Todo 構造体と、失敗したときのエラーを返す
実際に実装すると以下のようになります。
func GetTodoService(id int) (models.Todo, error) {
// TODO : sqlx.DB 型を手に入れて、変数 db に代入する
// 指定IDのTODOをDBから取得する
todo, err := repositories.GetTodo(db, id)
if err != nil {
return models.Todo{}, err
}
return todo, nil
}
1-3. サービス層で使用する sqlx.DB
型の入手
GetTodoService
関数を完成させるにはDBに接続してsqlx.DB
型を取得する必要があります。
repositories 層の関数は全て、接続するデータベースを引数で受け取る形になっているので、そこに渡すために sqlx.DB
型をこの時点で手に入れておく必要があります。
ただこの実装はサービス層がデータベース接続に直接依存しているので良くない設計です。
次の章でこの部分を含め全体のリファクタリングを行うのでここではひとまずsqlx.DB
型を得る connectDB
関数をサービス層の中で定義して、それを使う形にしたいと思います。
services ディレクトリ内に helper.go
ファイルを新たに作り、その中に関数定義を記述します。
package services
import (
"fmt"
_ "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
)
var (
dbUser = "docker"
dbPassword = "password"
dbHost = "127.0.0.1"
dbPort = "3306"
dbName = "testdb"
)
func connectDB() (*sqlx.DB, error) {
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 {
return nil, err
}
return db, nil
}
あとはこの関数をGetTodoServiceで呼び出します。
func GetTodoService(id int) (models.Todo, error) {
db, err := connectDB()
if err != nil {
return models.Todo{}, err
}
defer db.Close()
todo, err := repositories.GetTodo(db, id)
if err != nil {
return models.Todo{}, err
}
return todo, nil
}
1-4. 環境変数の利用
ここでDBパスワードやユーザー名などの機密情報がハードコーディングされている問題を修正しようと思います。
Goではos
パッケージ内にGetenv
関数が存在します。
func Getenv(key string) string
この関数を使用することで指定したキーの環境変数の値を取得することができます。
これによりGo のコードの中から環境変数の値の読み込みができ、「環境変数にセットされた値をパスワードとして使う」みたいなことができます。
var (
dbUser = os.Getenv("MYSQL_USER")
dbPassword = os.Getenv("MYSQL_PASSWORD")
dbHost = "127.0.0.1"
dbPort = os.Getenv("DB_PORT")
dbName = os.Getenv("MYSQL_DATABASE")
)
func connectDB() (*sqlx.DB, error) {
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 {
return nil, err
}
return db, nil
}
これにより.env
で定義した環境変数を取得することができ機密情報をハードコーディングする必要がなくなりました。
2. (実践演習) 残りのサービス層の実装
残りのサービス層の関数の実装を行っていきましょう。
実際に1. サービス層の実装
で実装した流れで実装できるといいです。
ただ今回はシンプルなAPIなのでハンドラ層で必要となる処理の洗い出しはwantくらいで考えてもらえると。
実装例:https://github.com/aaaasahi/go-todo-app/blob/feature/5_add-service/services/todo_service.go
3. (実践演習) ハンドラ層からの呼び出し
最後に実装したサービス層の関数をハンドラの中から呼び出す処理を書いていてTODO APIを完成させましょう。
実装例:https://github.com/aaaasahi/go-todo-app/blob/feature/5_add-service/handlers/handlers.go
まとめ
この章でようやくTODO APIが完成しました。
ここで終わりでもいいのですが現在のTODO APIはただ動くだけで設計的には良くない部分がいくつかあるので次の6章でより良い構成かつより良いコードで動かすということを目標にしていきましょう。
次が最後の章です。最後頑張っていきましょう!
6章 アーキテクチャのリファクタリング
5章までの実装によりTODO APIは動くようになっていますが、ここからはただ動くだけでなくより良い構成かつより良いコードで動くことを目標にしていきます。
現状のコードでは、データベース接続処理を各所で繰り返し実行していることや、層間の依存関係が強く結合していることなど、後々のメンテナンス性に課題があります。
ここでは「サービス層」「コントローラ層」「ルータ層」といった構造を導入し、依存性の注入 (Dependency Injection) やインターフェースによる抽象化を行うことで、コードを整理・改善していきたいと思います。
1. サービス層を大改装 (依存性の注入と構造改善)
まずはサービス層を改装していきます。
1-1. 現状の問題点: サービス層とデータベースの強い依存
現在の実装では、ハンドラ層から呼ばれるサービス関数内でデータベースOpenを行い処理後にCloseするという動作を毎回しています。
func ListTodosService() ([]models.Todo, error) {
// 毎回データベース接続をオープン
db, err := connectDB()
if err != nil {
return nil, err
}
// 関数終了時にクローズ
defer db.Close()
todos, err := repositories.ListTodos(db)
if err != nil {
return nil, err
}
return todos, nil
}
サービス層の関数内で毎回sql.DB
型をOpen・Closeする構造には、大きく2つの問題があると考えます。
※ 今回はsqlxを使用しているので実際はsqlx.DB
型
1. パフォーマンスと接続管理の問題
Go公式ドキュメントによれば、sql.Open
は通常アプリケーション開始時に一度呼び出し、生成したsql.DB
を使い回すのが望ましいとされています。
毎回Open/Closeを繰り返すと無駄に接続の確立と解放を繰り返すことになり、効率が悪いだけでなくエラー発生源にもなり得えるので頻繁なOpen/CloseはGo公式で非推奨と明言されています。
2. 構造上の問題(結合度の高さ)
サービス層のコードがデータベース接続処理に強く依存している点もいけてないです。
サービス層は本来ビジネスロジック(アプリケーション固有の処理)を担うべき部分ですが、現状は各サービス関数がDB接続の詳細まで踏み込んでおり、サービス層とデータベース層が密結合になっています。
このままでは、例えば将来データベースをMySQLからPostgreSQLに変更する場合にサービス層まで大幅な修正が必要になったり、データベース接続失敗がそのままサービス層の失敗となって本来のビジネスロジック以前でエラーになってしまう、といった弊害があります。
これらの問題を解決するためにサービス層のリファクタリングを行います。
具体的な方針としては、データベース接続を最初に一度だけ実行し、それをサービス層に渡して使い回すように変更していきます。これによりサービス層内部からデータベース依存を排除し、構造をシンプルにできます。
1-2. サービス構造体の導入と依存性の注入
まず、データベース接続を保持するためのサービス構造体を定義します。サービス層のディレクトリに新しくservice.go
というファイルを作成し、以下のようにサービス構造体とコンストラクタ関数を定義します。
package services
import (
"github.com/jmoiron/sqlx"
)
// サービス構造体の定義(Todoサービス)
type TodoService struct {
db *sqlx.DB // フィールドに*sqlx.DB(データベース接続)を保持
}
// コンストラクタ関数
// 外部から*sqlx.DBを受け取り、サービス構造体を生成
func NewTodoService(db *sqlx.DB) *TodoService {
return &TodoService{db: db}
}
上記ではTodoService
という構造体にデータベース接続用のフィールドdb
を持たせ、NewTodoService
でそのフィールドに実際のDB接続オブジェクトを注入しています。
これによって、サービス層の中で必要となる*sql.DB
(正確には*sqlx.DB
)をあらかじめ外部から与えることが可能になります。
続いて、既存のサービス関数群をこの構造体のメソッドに移行します。
具体的には、services/todo_service.go
内で定義していた関数をTodoService
のレシーバメソッドに書き換えます。
func (s *TodoService) ListTodosService() ([]models.Todo, error) {
// 既に保持しているDB接続(s.db)を使用してリポジトリの関数を呼び出す
todos, err := repositories.ListTodos(s.db)
if err != nil {
return nil, err
}
return todos, nil
}
同様に他のサービス関数の書き換えを行ってください。
これにより各メソッドで毎回DBへの新規接続を開く必要がなくなり、アプリケーション起動時に開いた一つの接続を使い回す形に切り替わりました。
1-3. main関数でのDB初期化とサービス層への受け渡し
サービス構造体へデータベースを注入するため、アプリケーション起動時(main
関数)で一度だけデータベース接続を確立し、それをサービス構造体に渡すようにしていきます。
まず既存のservices/helper.go
でデータベース接続処理(環境変数から接続情報を取り込みsql.Open
を呼ぶ処理など)の責務をmain関数側に移します。
var (
dbUser = os.Getenv("MYSQL_USER")
dbPassword = os.Getenv("MYSQL_PASSWORD")
dbHost = "127.0.0.1"
dbPort = os.Getenv("DB_PORT")
dbName = os.Getenv("MYSQL_DATABASE")
)
func main() {
// DB接続の確立(アプリ全体で一度きり)
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()
e := echo.New()
// ルーティング処理
}
この変更によりservices/helper.go
自体が不要になるので削除しておきましょう。
続いてDBへの接続を確立できた*sqlx.DB
をNewTodoService
に渡します。
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()
// サービス構造体の初期化(DBを注入)
service := services.NewTodoService(db)
e := echo.New()
// ルーティング処理
}
これによりTodoService
内部では常にこのdb
フィールドを使ってクエリを実行するため、アプリケーション全体でDB接続を一つに統一することができました。
次は、この新しくなったサービス層を実際に利用するコントローラ層を作り、ハンドラのコードを書き換えていきましょう。
2. サービス層を使う側を大改装 (ハンドラからコントローラへ)
サービス層を改良しただけでは、まだコード全体の依存関係は整理しきれていません。
今度はサービス層を利用する側、具体的にはHTTPリクエストを受け取ってサービスを呼び出すハンドラ部分の構造を改善します。
ここでは新たにコントローラ層を導入し、ハンドラ関数群をコントローラ構造体のメソッドに置き換えていきます。
2-1. 現在の問題と実装方針
現在の実装では、handlers
パッケージに各種ハンドラ関数(例: GetTodosHandler
, CreateTodoHandler
など)が定義され、それらが直接services
パッケージ内の関数を呼び出しています。
しかしこの形だと、ハンドラ関数がサービス関数に直接に依存していて、テストの差し替えや振る舞いの変更がしにくい状態です。
サービス層とハンドラ層の依存関係を疎結合にするため、コントローラ層ではサービス構造体をフィールドに持つコントローラ構造体を用意し、ハンドラ処理をそのメソッドとして実装します。
こうすることで、ハンドラ(コントローラ)は具体的なサービス実装に直接依存せず、外部から与えられたサービス(のインターフェース)を使う形にリファクタできます。
2-2. コントローラ構造体の作成
現在の handlers
ディレクトリを controllers
ディレクトリに、 handlers.go
を controllers.go
に名前を変えましょう。
このcontrollers.go
でTodo用のコントローラ構造体を定義します。
サービスと同様にコンストラクタも用意します。
package controllers
import (
"go-todo-app/services"
"github.com/labstack/echo/v4"
)
// コントローラ構造体:サービスへの依存を内部に保持
type TodoController struct {
service *services.TodoService
}
// コンストラクタ関数:サービス構造体を受け取ってコントローラを生成
func NewTodoController(s *services.TodoService) *TodoController {
return &TodoController{ service: s }
}
TodoController
では内部に*TodoService
をフィールドとして持たせるようにします。
このようにコントローラ層にサービス層を埋め込むことで、ハンドラからサービスへの依存を間接化できます。
具体的には、後でmain関数でコントローラを生成するときにサービスを渡すことで、依存性の注入が行えるようになります。
2-3. ハンドラ関数のメソッドへの置き換え
次に、既存のハンドラ関数をTodoController
のメソッドとして再実装します。Echoフレームワークを使用しているため、メソッドのシグニチャは元のハンドラ関数と同じくfunc(echo.Context) error
となるようにします。
以下はいくつかのハンドラをコントローラメソッドに書き換えた例です(元のhandlersパッケージ内の関数と比較しながら示します)。
func (ctrl *TodoController) GetTodosHandler(c echo.Context) error {
// 自身が保持するサービスのメソッドを呼ぶ
todos, err := ctrl.service.ListTodosService()
if err != nil {
return c.String(http.StatusInternalServerError, "Failed to fetch todos")
}
return c.JSON(http.StatusOK, todos)
}
こうすることで、コントローラでは、受け取ったHTTPリクエストをパースしてサービス層の対応メソッドを呼び出し、その結果をHTTPレスポンスとして返す役割のみを行うようになります。
処理の流れ自体は従来と変わりませんが、呼び出す先がservices.XxxService(...)
からc.service.Xxx(...)
に変わっています。コントローラは自前でビジネスロジックを持たず、すべてサービス層に委譲するため、将来的にサービスの実装が変わってもコントローラ側のコードは基本的に変更不要になります。
2-4. main関数でのコントローラ生成と適用
サービス層と同様に、main関数でコントローラ構造体を生成し、それをルーティングに利用するよう修正します。
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()
// サービス構造体の初期化(DBを注入)
service := services.NewTodoService(db)
// コントローラーの初期化(サービスを注入)
controller := controllers.NewTodoController(service)
e := echo.New()
e.GET("/todos", controller.GetTodosHandler)
e.GET("/todos/:id", controller.GetTodoHandler)
e.POST("/todos", controller.CreateTodoHandler)
e.PUT("/todos/:id", controller.UpdateTodoHandler)
e.DELETE("/todos/:id", controller.DeleteTodoHandler)
log.Println("server start at port 8080")
if err := e.Start(":8080"); err != nil {
log.Fatal(err)
}
}
NewTodoController
にサービスインスタンスを渡してコントローラを作成し、そのメソッドを各ルートに紐付けています。
この変更によりハンドラ層(コントローラ)とサービス層の依存関係は明確かつ疎結合になりました。
ハンドラは具体的なサービス処理を知らず、与えられたサービスのメソッドを呼ぶだけなので、将来サービスの実装を変更したりモックに差し替えたりすることも容易になります。
3. ルータ層の作成
続いてmain関数に集中している処理の一部を分離し、ルータ層を導入します。
現状のmain関数では以下のような手順をすべてまとめて行っています。
- データベースの用意
- サービス構造体の作成
- コントローラ構造体の作成
- ルータ(Echo)を作り、パスとハンドラ関数の対応付けを登録する
- サーバーの起動
この中で4.のルーティング設定は、エンドポイントが増えるほどコード量が膨れ上がり、main
関数の見通しを悪くします。
そこで、ルーティングの登録処理を切り出し、main
関数をよりシンプルに保つようにしていきます。
3-1. ルータ層を作る
プロジェクト直下にrouterディレクトリを作成してディレクトリの中にrouter.go
というファイルを作成します。
作成したrouter.go
の中でアプリの全ルーティングを設定する処理をそこにまとめます。
package router
import (
"go-todo-app/controllers"
"github.com/labstack/echo/v4"
)
func NewRouter(ctrl *controllers.TodoController) *echo.Echo {
e := echo.New()
// すべてのルートとコントローラのハンドラメソッドを対応付け
e.GET("/todos", ctrl.GetTodosHandler)
e.GET("/todos/:id", ctrl.GetTodoHandler)
e.POST("/todos", ctrl.CreateTodoHandler)
e.PUT("/todos/:id", ctrl.UpdateTodoHandler)
e.DELETE("/todos/:id", ctrl.DeleteTodoHandler)
return e
}
NewRouter
関数はコントローラを受け取り、内部でEchoのルーターを初期化しつつ、エンドポイントとコントローラの各メソッドの紐付けを行っています。
これにより、エンドポイント定義を一箇所に集約でき、追加・変更も容易になります。
3-2. main関数のルーティング初期化の移譲
ルータ層を導入したら、main
関数でその関数を呼び出すようにしていきましょう。
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()
// サービス構造体の初期化(DBを注入)
service := services.NewTodoService(db)
// コントローラ構造体の初期化(サービスを注入)
controller := controllers.NewTodoController(service)
// ルータ層にルーティング構築を委譲
// ルータ層でEchoとルート設定を生成
e := router.NewRouter(controller)
log.Println("server start at port 8080")
if err := e.Start(":8080"); err != nil {
log.Fatal(err)
}
}
これでmain
関数におけるルーティング設定の記述がなくなり、だいぶスッキリしました。
ルータ層を導入することで、具体的なパスとハンドラの紐付けといった詳細をmainから切り離すことができるかつエンドポイントが増えても、routerパッケージ内で管理できるためmainが肥大化しません。
ここまでで、データベース→サービス→コントローラ→ルータという各層の役割がそれぞれ独立し、main
関数はそれらを組み立ててサーバを起動するだけという状態になりました。
4. インターフェースによる抽象化
これまでの変更でアーキテクチャはかなり改善されました。
さらにコードを疎結合にするためにインターフェースによる抽象化を行っていきます。
ここではサービス層をインターフェースで抽象化し、コントローラ層との結合度を一段と下げてみましょう。
4-1. サービスインターフェースの定義
まず、サービス層に対するインターフェースを定義します。
サービスが提供する機能(メソッド)をインターフェースとして宣言し、コントローラはそのインターフェース経由でサービスを利用する設計にします。
services/todo_service.go
にTodoサービスが提供するメソッドのインターフェースを定義します
package services
import (
"go-todo-app/models"
"go-todo-app/repositories"
)
// Todoサービスが提供するメソッドのインターフェース
type TodoServiceIF interface {
ListTodos() ([]models.Todo, error)
GetTodo(id int) (models.Todo, error)
CreateTodo(todo models.Todo) (models.Todo, error)
UpdateTodo(todo models.Todo) (models.Todo, error)
DeleteTodo(id int) error
}
TodoServiceIF
インターフェースは、Todoサービス構造体が持つ一連のメソッドを網羅した契約になります。
続いて既存のTodoService
構造体がこのインターフェースを実装するようにします。
ただ、既に構造体が上記メソッドをすべて実装しているため暗黙的にインターフェースを満たしていて追加のコードは不要となっています。
4-2. コントローラでサービスインターフェースを利用
コントローラ側では、フィールドを具体的な*TodoService
ではなく、このインターフェース型に変更します。加えてコンストラクタもインターフェースを受け取るよう修正します。
またGo ではインターフェースのポインタをとることができないためポインタを使用しない形で変更しています。
type TodoController struct {
// インターフェース型に変更し、具体実装に依存しないように
service services.TodoServiceIF
}
func NewTodoController(s services.TodoServiceIF) *TodoController {
return &TodoController{service: s}
}
こうすることで、コントローラはサービスの具体型(TodoService構造体)ではなく、TodoServiceIF
インターフェースに対して依存する形になります。
これにより、サービス層とコントローラ層の関係を抽象化できサービスとコントローラの結合度合いがさらに下がり、アーキテクチャ全体がよりモジュール化されよりテストの容易性が向上し将来の拡張が柔軟になりました。
まとめ
今回の変更でTODO APIのプロジェクト構成は、
main関数 → ルータ → コントローラ → サービス → リポジトリ(DB)
というレイヤー構造になりました。
各レイヤー間は明確にインターフェースや注入によって接続されており、それぞれの責務がはっきり分かれました。
これでただ動くだけでなく良い構成かつより良いコードで動くになりました。
これでTODO APIは完成です。
この章での変更差分:https://github.com/aaaasahi/go-todo-app/pull/7
さいごに
初心者向けのハンズオン教材を作るという軽いノリで書き始めましたが、シンプルな構成なのに前編後編とかなりボリューミーなハンズオンとなってしまいました😭
ハンズオンとしてはここまででですが、これからはユーザー認証をつけたりテーブルを増やしたりと今回作ったTODO APIを拡張してGoでのより実践的なアプリ開発をして欲しいです。