先日上記記事でアウトプットした知識から、より理解を深めるため、ToDoリストをイメージしたシンプルなCRUDのREST APIをDDD + レイヤードアーキテクチャで実装してみました。
おさらい
レイヤードアーキテクチャの概念であるdomain層を確立させ、そこにドメインロジックを凝集させようという発想に、DDDのDIP(依存関係逆転の原則)を用いることで、上の図のような各レイヤごとの依存関係を意識した実装を行います。
実現したい機能を抽象化し、各レイヤごとの依存関係を意識しながら実装に落とし込んでいく作業が、従来のMVCで書いていた時よりも格段に頭を使うことを実感しています。
自分が実装している範囲内ではまだまだこのアーキテクチャを活かしきれていないですが、少しづつ理解を深めてものにしていきたいです。
domain層
Domain層は、システムが扱う業務領域に関するコードを置くところです。今回ToDoリストで必要なItemモデルを定義します。
今回はシンプルなToDoリストなのでこれだけですが、実際はここに実現したいシステムの業務領域を集約させて、全体の開発に展開させていきます。
・domain/model/item.go
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | package model import ( "time" _ "github.com/jinzhu/gorm/dialects/mysql" ) type Item struct { Id int `gorm:"unique;not null"` Name string `gorm:"size:255"` Status int `gorm:"not null"` CreatedAt time.Time UpdatedAt time.Time } |
repositoryはDBとのやりとりを定義しますが、技術的関心ごとはinfrastructure層に書くため、ここではインターフェースとしてメソッドを定義します。
今回のToDoリストでは一覧取得、タスク登録、タスク更新のメソッドを定義します。実際の処理はinfrastructure層に書き、domain層に依存するように実装します。
ちなみに削除も追加しようと思ったのですが、ToDoリストの場合はステータスで判断するため、削除はUpdate()で行われることを想定しています。
もし削除機能を追加する場合は、DeletedAtカラムを追加して論理削除するようにします。Gormだとdeleteするとデフォルトで論理削除し、データ取得時もDeletedAtがnullのレコードを取得するようにしてくれます。
・domain/repository/item.go
1 2 3 4 5 6 7 8 9 10 11 12 | package repository import ( "github.com/sample/go-ddd-crud/domain/model" ) // ItemRepository : Item における Repository のインターフェース type ItemRepository interface { FindAll() (items []*model.Item, err error) Create(item *model.Item) (err error) Update(item *model.Item) (err error) } |
infrastructure層
infrastructure層ではdomain層に依存するように実装します。
先ほどdomain層でインターフェースとして定義したFindAll()、Create()、Update()の3つの技術的処理を書いていきます。
FindAll()では、ToDoリスト一覧を取得し、その結果を返すようにします。
Create()では、POSTで送られてきたパラメータを元にToDoタスクを登録します。
Update()では、PUTで送られてきたパラメータを元にToDoタスクを更新します。
・infrastructure/persistence/item.go
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 | package persistence import ( "time" "github.com/jinzhu/gorm" "github.com/sample/go-ddd-crud/domain/model" "github.com/sample/go-ddd-crud/domain/repository" ) type itemPersistence struct { Conn *gorm.DB } // NewItemPersistence : Item データに関する Persistence を生成 func NewItemPersistence(conn *gorm.DB) repository.ItemRepository { return &itemPersistence{Conn: conn} } // Search : DB から Item データの全件取得(ItemRepository インターフェースの Index() を実装したもの) func (ip *itemPersistence) FindAll() (items []*model.Item, err error) { // DB接続 db := ip.Conn if err := db.Find(&items).Error; err != nil { return nil, err } return items, nil } // Create : DB から Item データの新規登録(ItemRepository インターフェースの Create() を実装したもの) func (ip *itemPersistence) Create(item *model.Item) (err error) { // DB接続 db := ip.Conn now := time.Now() // 新規登録用Item生成 createItem := &model.Item{ Name: item.Name, Status: item.Status, CreatedAt: now, UpdatedAt: now, } // 新規登録 if err := db.Create(&createItem).Error; err != nil { return err } return nil } // Update : DB から Item データの更新(ItemRepository インターフェースの Update() を実装したもの) func (ip *itemPersistence) Update(item *model.Item) (err error) { // DB接続 db := ip.Conn now := time.Now() // 更新対象のItem取得 updateItem := &model.Item{} if err := db.First(&updateItem, item.Id).Error; err != nil { return err } updateItem.Name = item.Name updateItem.Status = item.Status updateItem.UpdatedAt = now // 更新 if err := db.Save(&updateItem).Error; err != nil { return err } return nil } |
usecase層
今回はシンプルなCRUD処理しかしないので、この層の存在価値がわかりにくいですが、複雑なビジネスロジックがあるときは、この層の存在価値が発揮されるようです。
Itemの取得や登録などでDBにアクセスする時にdomain層のrepositoryを介してアクセスすることによって、infrastructure層ではなくdomain層のみに依存させています。
この層で送られてきたパラメータのバリデートしていますが、実際はこの層でバリデートするのが正しいのかちょっとまだわかっていないです。。
usecase層の責務として、interface層から情報を受け取り、domain層で定義してあるメソッドを用いてビジネスロジックを実行することを考えると、この層で行うのが正しい気がしています。
・usecase/item.go
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | package usecase import ( "errors" "github.com/sample/go-ddd-crud/domain/model" "github.com/sample/go-ddd-crud/domain/repository" ) // ItemUseCase : Item における UseCase のインターフェース type ItemUseCase interface { FindAll() (items []*model.Item, err error) Create(status int, name string) (err error) Update(id, status int, name string) (err error) } type itemUseCase struct { itemRepository repository.ItemRepository } // NewItemUseCase : Item データに関する UseCase を生成 func NewItemUseCase(ir repository.ItemRepository) ItemUseCase { return &itemUseCase{ itemRepository: ir, } } // Search : Item データを全件取得するためのユースケース func (iu itemUseCase) FindAll() (items []*model.Item, err error) { // Persistence(Repository)を呼び出し items, err = iu.itemRepository.FindAll() if err != nil { return nil, err } return items, nil } // Create : Item データを新規登録するためのユースケース func (iu itemUseCase) Create(status int, name string) (err error) { // Item 引数から構造体生成 item := &model.Item{ Name: name, Status: status, } // バリデーションを呼び出し if err := validate(item); err != nil { return err } // Persistence(Repository)を呼び出し err = iu.itemRepository.Create(item) if err != nil { return err } return nil } // Update : Item データを更新するためのユースケース func (iu itemUseCase) Update(id, status int, name string) (err error) { // Item 引数から構造体生成 item := &model.Item{ Id: id, Name: name, Status: status, } // バリデーションを呼び出し if err := validate(item); err != nil { return err } // Persistence(Repository)を呼び出し err = iu.itemRepository.Update(item) if err != nil { return err } return nil } // Validate : Item データをバリデート func validate(item *model.Item) error { if len(item.Name) >= 200 { return errors.New("タスク名は200文字未満で書いてください。") } return nil } |
interface層
interface層は、usecase層と切り離すことでリクエストやレスポンスの形に変わってもinterface層の修正だけで済むようになります。
Index()では、usecase層のメソッドを呼び出してToDoリスト一覧を取得し、返り値をJSON形式に変換しレスポンスとして返却しています。
Create()とUpdate()では、パラメータを引数にusecase層のメソッドを呼び出し、正常に成功した場合はHTTPレスポンスコード 200 No Contentで返すようになっています。http.Error(w, “成功”, 200)のように明示的にHTTPレスポンスコードを指定してハンドリングすこともできます。
DB接続やバリデートでエラーが発生した場合は、HTTPレスポンスコード 500でerr.Error()によるエラーメッセージを返却します。
・interface/handler/item.go
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 | package handler import ( "encoding/json" "fmt" "net/http" "strconv" "github.com/julienschmidt/httprouter" "github.com/sample/go-ddd-crud/usecase" ) // ItemHandler : Item における Handler のインターフェース type ItemHandler interface { Index(http.ResponseWriter, *http.Request, httprouter.Params) Create(http.ResponseWriter, *http.Request, httprouter.Params) Update(http.ResponseWriter, *http.Request, httprouter.Params) } type itemHandler struct { itemUseCase usecase.ItemUseCase } // NewItemUseCase : Item データに関する Handler を生成 func NewItemHandler(iu usecase.ItemUseCase) ItemHandler { return &itemHandler{ itemUseCase: iu, } } // ItemIndex : GET /items -> Item データの全件取得結果を返す func (ih itemHandler) Index(w http.ResponseWriter, r *http.Request, pr httprouter.Params) { // ユースケースの呼び出し items, err := ih.itemUseCase.FindAll() if err != nil { http.Error(w, err.Error(), 500) return } // クライアントにレスポンスを返却 if err = json.NewEncoder(w).Encode(items); err != nil { http.Error(w, err.Error(), 500) return } } // ItemCreate : POST /items -> Item データの新規登録結果を返す func (ih itemHandler) Create(w http.ResponseWriter, r *http.Request, pr httprouter.Params) { // パラメータ name := r.FormValue("name") status, _ := strconv.Atoi(r.FormValue("status")) // ユースケースの呼び出し err := ih.itemUseCase.Create(status, name) if err != nil { http.Error(w, err.Error(), 500) return } } // ItemUpdate : PUT /items -> Item データの更新結果を返す func (ih itemHandler) Update(w http.ResponseWriter, r *http.Request, pr httprouter.Params) { // パラメータ id, _ := strconv.Atoi(r.FormValue("id")) name := r.FormValue("name") status, _ := strconv.Atoi(r.FormValue("status")) // ユースケースの呼び出し err := ih.itemUseCase.Update(id, status, name) if err != nil { http.Error(w, err.Error(), 500) return } } |
main.go
main.goでは依存関係の定義とルーティングを設定しています。各層の依存関係を定義することで、利用可能な状態にします。
- DBを操作するためconfig.Connect()でDB情報をitemPersistence(repository.ItemRepositoryを満たす)に注入
- そのitemPersistenceをusecase層のitemUsecase(repository.ItemRepositoryをフィールドに持つ)に注入
- 生成したitemUsecaseをitemHandler(itemUsecaseをフィールドに持つ)に注入
また、サーバ起動のタイミングであわせてCORSの設定もしておきます。
・main.go
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 | package main import ( "fmt" "log" "net/http" "github.com/julienschmidt/httprouter" "github.com/sample/go-ddd-crud/config" "github.com/sample/go-ddd-crud/infrastructure/persistence" handler "github.com/sample/go-ddd-crud/interfaces/handler" "github.com/sample/go-ddd-crud/usecase" ) func main() { // 依存関係を注入 itemPersistence := persistence.NewItemPersistence(config.Connect()) itemUseCase := usecase.NewItemUseCase(itemPersistence) itemHandler := handler.NewItemHandler(itemUseCase) // ルーティングの設定 router := httprouter.New() router.GET("/api/items", itemHandler.Index) router.POST("/api/items", itemHandler.Create) router.PUT("/api/items", itemHandler.Update) // サーバ起動 fmt.Println("------------------------") fmt.Println("サーバ起動 http://localhost:8080") fmt.Println("------------------------") http.ListenAndServe(":8080", &Server{router}) log.Fatal(http.ListenAndServe(":8080", router)) } type Server struct { r *httprouter.Router } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Add("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET POST PUT") w.Header().Add("Access-Control-Allow-Headers", "Content-Type") w.Header().Add("Access-Control-Allow-Headers", "Origin") w.Header().Add("Access-Control-Allow-Headers", "X-Requested-With") w.Header().Add("Access-Control-Allow-Headers", "Accept") w.Header().Add("Access-Control-Allow-Headers", "Accept-Language") w.Header().Set("Content-Type", "application/json") s.r.ServeHTTP(w, r) } |
DB設定
開発環境と本番環境のDB設定はdatabase.goに書いています。.env.developmentと.env.productionにそれぞれの環境におけるDB情報を定義しておき実行環境変数から取得しています。
db.AutoMigrate(&model.Item{})でサーバ起動時にItemテーブル生成のマイグレーションが実行されます。
ちょっと疑問なのが、すでに生成されたテーブルのカラム変更をしたい場合はどのようにするのでしょうかね。Railsのようにマイグレーションファイルで履歴管理するみたいな感じではないので、別途マイグレーション関連のツールを導入するのが正しいのでしょうか。
→ 調べてみたら以下のようにカラムの型変更などができるみたいです。
1 2 | // `User`モデルのdescriptionカラムのデータ型を`text`に変更します db.Model(&User{}).ModifyColumn("description", "text") |
参考:
GOのORMを分かりやすくまとめてみた【GORM公式ドキュメントの焼き回し】 #Go - Qiita
・config/database.go
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | package config import ( "os" "github.com/sample/go-ddd-crud/domain/model" "github.com/jinzhu/gorm" _ "github.com/jinzhu/gorm/dialects/mysql" "github.com/joho/godotenv" ) var ( db *gorm.DB err error ) // DB接続 func Connect() *gorm.DB { // 実行環境取得 env := os.Getenv("HAKEN_ENV") if "production" == env { env = "production" } else { env = "development" } // 環境変数取得 godotenv.Load(".env." + env) godotenv.Load() // DB接続 db, err = gorm.Open("mysql", os.Getenv("CONNECT")) if err != nil { panic(err) } autoMigration() return db } // DB終了 func Close() { if err := db.Close(); err != nil { panic(err) } } // Item マイグレーション func autoMigration() { db.AutoMigrate(&model.Item{}) } |
動作確認
最後にTalend API Testerを使って動作確認をします。
・POSTでToDoタスク新規登録
・GETで新規登録したToDoタスクを取得
・PUTで新規登録したToDoタスクを更新
・GETで更新したToDoタスクを取得
・バリデーションエラー時
・DB接続エラー時
参考
Goプログラミング実践入門
実践ドメイン駆動設計
【Go + レイヤードアーキテクチャー】DDDを意識してWeb APIを実装してみる - yyh-gl's Tech Blog
DDDを意識しながらレイヤードアーキテクチャとGoでAPIサーバーを構築する #ドメイン駆動設計 - Qiita