この記事はSchoo Advent Calendar2024の14日目の記事です!
株式会社Schoo 新卒1年目の @hiroto_0411です!
DI(依存性の注入)がなぜ必要なのかを理解するのに苦戦していたのですが、業務や研修を通して段々と理解を深めることができたので、DIについてまとめてみました!
DIの理解に苦戦していた私ですが、DIは実際にコードを書いてみると理解を深めることができるなぁと実感したので、記事を参考にコードを書きながら読んでもらえたら嬉しいです!
対象読者
- なぜDI実装をすべきなのか知りたい方
- GoでのDI実装方法を知りたい方
DIとは
Dependency Injection(依存性の注入)のことです。依存性を外部から注入することを指します。
その結果、上位モジュールを下位モジュールの具体的な実装に直接依存させないようにするデザインパターンです。上位モジュールが具体的な実装ではなく、抽象であるinterfaceに依存することで、実装の詳細を知らずに実装できます。これによるメリットは、モジュール間の結合度が低くなり、テストや保守が容易になります。
なぜinterfaceに依存するとよいのか?
こちらの図では、DIを実装しておらずcontroller層がmodel層の実装に依存してしまっています。
一方こちらでは、DIを実装することでmodel層はinterfaceを実装すればよく、controller層はinterfaceに依存するためmodelの実装に依存しなくなります。
interfaceに依存させるメリット
- 他のモジュールの実装ではなく、interfaceに依存することでモジュール同士が疎結合になる
- 疎結合になり上位のモジュールが下位のモジュールについて知らなくなることで、テストがしやすくなる
- 下位のモジュールに変更が加わっても(DBからのデータの取り方が変わった、外部APIの使用が変わった など)影響を受けにくい。下位モジュールの実装が変わっても、interfaceを実装していれば問題ない
文章で読んでいるだけだと分かりにくいと思うので実際にコードで確認してみましょう!
GoでDIを実装してみる
DIできていない例
ディレクトリ構成
.
├── controller
│ ├── todo.go
│ └── todo_test.go
├── go.mod
├── go.sum
├── main.go
└── model
└── todo.go
model層
package model
import "gorm.io/gorm"
type Todo struct {
ID int
Title string
}
type TodoModel struct {
DB *gorm.DB
}
func NewTodoModel(db *gorm.DB) *TodoModel {
return &TodoModel{DB: db}
}
func (m *TodoModel) FetchTodos() ([]Todo, error) {
var todos []Todo
if err := m.DB.Find(&todos).Error; err != nil {
return nil, err
}
return todos, nil
}
controller層
package controller
import (
"golang-playground/model"
"log"
"gorm.io/gorm"
)
type TodoController struct {
Model *model.TodoModel
}
// model層でgorm.DBを使っていることを知っている。つまりmodel層に依存している。
func NewTodoController(m *model.TodoModel) *TodoController {
return &TodoController{Model: m}
}
func (c *TodoController) FetchTodos() ([]model.Todo, error) {
posts, err := c.Model.FetchTodos()
if err != nil {
log.Println("Failed to get todos: ", err)
return nil, err
}
return posts, nil
}
controllerがmodelにfunc NewTodoController(m *model.TodoModel)*TodoController
のような形で直接依存してしまっています。直接依存することでmodelに変更が加わった場合に影響を受けやすくなってしまっています。
次のテストコードでどのような影響があるのか見てみましょう!
テストコード
package controller
import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestFetchTodos(t *testing.T) {
// モックDBの作成
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("Failed to create sqlmock: %v", err)
}
defer db.Close()
// sqlite_version()を設定
// SQLiteを使っていることを知ってしまっている
mock.ExpectQuery(`select sqlite_version\(\)`).WillReturnRows(
sqlmock.NewRows([]string{"sqlite_version"}).AddRow("3.36.0"),
)
// gorm.DB を初期化
gormDB, err := gorm.Open(sqlite.Dialector{Conn: db}, &gorm.Config{})
if err != nil {
t.Fatalf("Failed to create gorm.DB: %v", err)
}
// モックのクエリと返り値の設定
mock.ExpectQuery("SELECT \\* FROM `todos`").WillReturnRows(
sqlmock.NewRows([]string{"id", "title"}).
AddRow(1, "Task1").
AddRow(2, "Task2"),
)
// TodoControllerのセットアップ
m := model.NewTodoModel(gormDB)
controller := NewTodoController(m)
// メソッドをテスト
todos, err := controller.FetchTodos()
// エラーがないことを確認
if err != nil {
t.Fatalf("Expected no error, but got %v", err)
}
// 期待する結果
expected := []model.Todo{
{ID: 1, Title: "Task1"},
{ID: 2, Title: "Task2"},
}
// reflect.DeepEqual で結果を検証
if !reflect.DeepEqual(todos, expected) {
t.Errorf("Expected %v, got %v", expected, todos)
}
// モックが期待通りに呼び出されたか確認
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("There were unmet expectations: %v", err)
}
}
テストを実装しようとするとmodel層での実装(gormを使っていることやSQLiteを使っていること)を完全に知った状態になってしまっています。gormやSQLiteを使わなくなったらこのテストコードも書き換える必要があるので良くない状態です。
mainの実装イメージ
DBを実装していないので実際の動作は確認していません。
package main
import (
"fmt"
"golang-playground/controller"
"golang-playground/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func main() {
// データベースの初期化
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
// モデル層の初期化
todoModel := model.NewTodoModel(db)
// controller層の初期化
todoController := controller.NewTodoController(todoModel.DB)
// controllerのメソッドを呼び出し
todos, err := todoController.FetchTodos()
if err != nil {
fmt.Printf("Error fetching todos: %v\n", err)
return
}
fmt.Println("Fetched Todos:")
for _, todo := range todos {
fmt.Printf("- ID: %d, Title: %s\n", todo.ID, todo.Title)
}
}
interfaceを使ってDIを実装してみる
ディレクトリ構成
.
├── controller
│ ├── todo.go
│ └── todo_test.go
├── go.mod
├── go.sum
├── main.go
└── model
├── iface
│ └── todo.go
├── implements
│ └── todo.go
└── todo.go
model層
package model
type Todo struct {
ID int
Title string
}
package implements
import (
"go-playground/model"
"go-playground/model/iface"
"gorm.io/gorm"
)
type TodoModel struct {
DB *gorm.DB
}
func NewTodoModel(db *gorm.DB) iface.TodoModeler {
return &TodoModel{DB: db}
}
func (m *TodoModel) FetchTodos() ([]model.Todo, error) {
var todos []model.Todo
m.DB.Find(&todos)
return todos, nil
}
package iface
import (
"go-playground/model"
)
type TodoModeler interface {
FetchTodos() ([]model.Todo, error)
}
ここでinterfaceを実装しています。interfaceは予約語でpackage名として使用できないためifaceとしています。
controller層
package controller
import (
"fmt"
"log"
"go-playground/model"
"go-playground/model/iface"
)
type TodoController struct {
Model iface.TodoModeler
}
// model.TodoModeler(interface)に依存している。つまり、model層の実装は知らず、interfaceを実装していれば引数に取れる。
func NewTodoController(m iface.TodoModeler) *TodoController {
return &TodoController{Model: m}
}
func (c *TodoController) FetchTodos() ([]model.Todo, error) {
posts, err := c.Model.FetchTodos()
if err != nil {
log.Println("Failed to get posts: ", err)
return nil, err
}
fmt.Println(posts)
return posts, nil
}
interfaceを使ってDIしたことで、controller層はinterfaceに依存する形になり、model層の実装を知らない状態にすることができました。(modelがgormを使っているかを気にせずに、controllerを実装できています)
テストコード
package controller
import (
"reflect"
"testing"
"go-playground/model"
)
type MockTodoModel struct {
models []model.Todo
err error
}
//interface(TodoModeler)を実装している
func (m *MockTodoModel) FetchTodos() ([]model.Todo, error) {
return m.models, m.err
}
// テストコード
func TestTodoController(t *testing.T) {
// モックのセットアップ
mockModel := &MockTodoModel{
models: []model.Todo{
{ID: 1, Title: "Task1"},
{ID: 2, Title: "Task2"},
},
err: nil,
}
// controllerのセットアップ
controller := NewTodoController(mockModel)
// メソッドの実行
todos, err := controller.FetchTodos()
if err != nil {
t.Error("Expected no error, got ", err)
}
expected := []model.Todo{
{ID: 1, Title: "Task1"},
{ID: 2, Title: "Task2"},
}
if !reflect.DeepEqual(todos, expected) {
t.Errorf("Expected %v, got %v", expected, todos)
}
}
controllerのセットアップの仕方に大きな違いがあるのが分かると思います。
DIできていない例ではcontroller := NewTodoController(gormDB)
となっており、gormを使っていることをcontroller層が知ってしまっていますが、今回の例ではcontroller := NewTodoController(mockModel)
となっており、mockを利用することができています。つまり、gormやSQLiteについて気にする必要がなくなっているのが分かると思います。よってmodel層の実装に変更が加わってもその影響を受けにくい状態になっています。コードもだいぶシンプルになりました!
mainの実装イメージ
package main
import (
"go-playground/controller"
"go-playground/model/implements"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func main() {
// データベースの初期化
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
//NewTodoModelでinterfaceを作成している
todoModel := implements.NewTodoModel(db)
//interface(依存性)をcontroller層へ注入している
todoController := controller.NewTodoController(todoModel)
todoController.FetchTodos()
}
まとめ
DIすることで「疎結合にできるよ!」「テストしやすくなるよ!」というのはよく聞きますが、「なぜそうなるのだろう?」というところを理解するのは難しく感じる方もいると思います。
自分で実装して試してみると理解を深めることができ、DIのメリットを実感することができるかなと思います。是非この記事を参考に色々試していただけたら嬉しいです!
参考
Schooでは一緒に働く仲間を募集しています!