22
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SchooAdvent Calendar 2024

Day 14

Goのコードを書きながらDIの理解を深めてみた

Posted at

この記事はSchoo Advent Calendar2024の14日目の記事です!

株式会社Schoo 新卒1年目の @hiroto_0411です!
DI(依存性の注入)がなぜ必要なのかを理解するのに苦戦していたのですが、業務や研修を通して段々と理解を深めることができたので、DIについてまとめてみました!

DIの理解に苦戦していた私ですが、DIは実際にコードを書いてみると理解を深めることができるなぁと実感したので、記事を参考にコードを書きながら読んでもらえたら嬉しいです!

対象読者

  • なぜDI実装をすべきなのか知りたい方
  • GoでのDI実装方法を知りたい方

DIとは

Dependency Injection(依存性の注入)のことです。依存性を外部から注入することを指します。
その結果、上位モジュールを下位モジュールの具体的な実装に直接依存させないようにするデザインパターンです。上位モジュールが具体的な実装ではなく、抽象であるinterfaceに依存することで、実装の詳細を知らずに実装できます。これによるメリットは、モジュール間の結合度が低くなり、テストや保守が容易になります。

なぜinterfaceに依存するとよいのか?

image.png
こちらの図では、DIを実装しておらずcontroller層がmodel層の実装に依存してしまっています。


image.png

一方こちらでは、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層

./model/todo.go
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層

./controller/todo.go
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に変更が加わった場合に影響を受けやすくなってしまっています。

次のテストコードでどのような影響があるのか見てみましょう!

テストコード

./controller/todo_test.go
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を実装していないので実際の動作は確認していません。

main.go
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層

./model/todo.go
package model

type Todo struct {
	ID    int
	Title string
}

./model/implements/todo.go
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
}

./model/iface/todo.go
package iface

import (
	"go-playground/model"
)

type TodoModeler interface {
	FetchTodos() ([]model.Todo, error)
}

ここでinterfaceを実装しています。interfaceは予約語でpackage名として使用できないためifaceとしています。

controller層

./controller/todo.go
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を実装できています)

テストコード

./controller/todo_test.go
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では一緒に働く仲間を募集しています!

22
7
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
22
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?