Go + DDD + レイヤードアーキテクチャを本やネットで調べ、実際に動かしながらREST APIを構築してみました。
ドメイン駆動設計の本や記事を読んでも実際に自分の手でやってみないと身につかないと思い、簡易的ですが使えるところまで実装してみました。
目次
DDD(domain driven design)とは
DDD(ドメイン駆動設計)とはソフトウェアの設計手法であり、ドメインモデリングに着目してソフトウェアの価値を高める手法です。
ソフトウェアの核心にある複雑さに立ち向かうため、チームの共通言語である「ユビキタス言語」を用いて「ドメインモデル」を構築し、それをコードとして実装します。
また、大規模で密結合なシステムにならないように「ドメイン」と「境界づけられたコンテキスト」にシステムを分割し、「コアドメイン」という最重要領域に集中して開発を行います。
DDDについてはまだ理解が浅いので、今はこれらの用語や設計思想についてざっくりとしかイメージできていませんが、今後開発しながら知見を深めていきたいと思います。
レイヤードアーキテクチャとは
従来のMVCなどの3層アーキテクチャに比べてdomain層を確立させ、そこにドメインロジックを凝集させようという発想のアーキテクチャです。
以下の図のように各レイヤごとに責務を切り分け、依存の方向を一方向にします。
純粋なレイヤードアーキテクチャでは上の図になるのですが、DDDではDIP(依存関係逆転の原則)を用いて下の図のような依存関係になります。
レイヤードアーキテクチャが必要な理由
レイヤードアーキテクチャが必要な理由として以下の2点が挙げられます。
- コードの依存関係を整理できる
- レイヤ内のパッケージの凝縮度を高めることができる
また、コード量が多くなると、何がどこに影響するのか管理しづらくなり可読性が低下します。レイヤードアーキテクチャを取り入れることで、システムの保守性が低下しないようにするための効果的なアプローチとなります。
個人的に依存関係が整理されることでシステム規模が大きくなった時や新しくチームにメンバーが入った時にもスムーズに開発に入りやすい点も大きな魅力に感じました。
各レイヤの責務と役割
それぞれのレイヤの責務と役割は以下のようになります。
interface層
- HTTPを受け取り、usecaseを呼び結果を出力(JSONやHTML)
→ 基本的に薄い実装になるが、HTTPのbodyやheaderのパース処理などで薄くならないこともあります。
usecase層
- アプリケーションレイヤでシステム使用上のユースケースを表現
→ ユーザ登録、ユーザ覧表示など
- handlerから呼びされる
→ 1つのhandlerに対応する専用usecaseが1つ存在します。
- 基本的にはdomainを触る
domain層
- モデルレイヤでドメイン(システムが扱う業務領域)に関する値と振る舞いを持つ
- 他のレイヤに依存しない
infrastructure層
- 技術的関心ごとを扱うレイヤで具体的な技術に関する処理を書く
→ DBやMail操作など
- 直接handler、usecaseから呼ばれることもあるが、基本的にdomainのインターフェースによって抽象化される
ディレクトリ構成
ディレクトリ構成は以下のようになりました。docker-compose.ymlでMySQL立ち上げて使用しています。
sample-api
├── config
│ └── database.go
├── domain
│ └── model
│ └── user.go
│ └── repository
│ └── user.go
├── infrastructure
│ └── persistence
│ └── user.go
├── interface
│ └── handler
│ └── user.go
├── usecase
│ └── user.go
├── .env.development
├── .env.production
├── docker-compose.yml
└── main.go
domain層
domain層ではmodelとrepositoryに分けています。上の図でも説明した通りドメイン層はどの層にも依存せずに隔離します。
ドメインロジックを集約させ、ここをインクリメンタルに成長させながら全体の開発に展開させていきます。modelは業務領域における構造体やビジネスロジックを置きます。
ちなみに下記のような属性と値を保持するだけのオブジェクトの状態は「ドメインモデル貧血症」と呼ばれています。
オブジェクト指向において、データ(フィールド変数)とそれについての操作(メソッド)は同じクラス内にあるべきであり、この状態はそうなっていないためです(必ずダメというわけではない)。今回は検索するだけのとてもシンプルなAPIなので本来はここに処理を書いていくことになると思います。
・domain/model/user.go
1 2 3 4 5 6 7 8 9 10 11 12 13 | package model import ( "time" _ "github.com/jinzhu/gorm/dialects/mysql" ) type User struct { Id uint name string CreatedAt time.Time } |
repositoryはDBとのやりとりを定義しますが、技術的関心ごとはinfrastructure層に書くため、ここではインターフェースとしてメソッドを定義します。
実際の処理はinfrastructure層に書き、domain層に依存するように実装します。
・domain/repository/user.go
1 2 3 4 5 | package repository type UserRepository interface { Search(name string) ([]*model.User, error) } |
infrastructure層
infrastructure層ではdomain層に依存するように実装します。
domain層で定義したインターフェースから実際の技術的処理を書いていきます。
今回はユーザ名を引数にして検索を行う処理を実装しています。DBはMySQLを使用し、DB操作のORMはGORMを使用しています。
・infrastructure/persistence/user.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 | package persistence import ( "github.com/jinzhu/gorm" "github.com/sample/sample-api/domain/model" "github.com/sample/sample-api/domain/repository" ) // UserにおけるPersistenceのインターフェース type userPersistence struct { Conn *gorm.DB } // Userデータに関するPersistenceを生成 func NewUserPersistence(conn *gorm.DB) repository.UserRepository { return &userPersistence{Conn: conn} } // 検索 func (up *userPersistence) Search(name string) ([]*model.User, error) { var user []model.User // DB接続確認 if err := up.Conn.Take(&user).Error; err != nil { return nil, err } db := up.Conn.Find(&user) // 名前検索 if name != "" { db = db.Where("name = ?", name).Find(&user) } return user, nil } |
usecase層
usecase層ではユースケースに沿った処理の流れを実装します。
今回は単純な処理しかしないので、この層の存在価値がわかりにくいですが、複雑なビジネスロジックがあるときは、この層の存在価値が発揮されるようです。
データの取得や保存などでDBにアクセスするときもdomain層のrepositoryを介してアクセスすることによって、infrastructure層ではなくdomain層のみに依存させています。
・usecase/user.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 | package usecase import ( "github.com/sample/sample-api/domain/repository" ) // UserにおけるUseCaseのインターフェース type UserUseCase interface { Search(name string) ([]*model.User, error) } type userUseCase struct { userRepository repository.UserRepository } // Userデータに関するUseCaseを生成 func NewUserUseCase(ur repository.UserRepository) UserUseCase { return &userUseCase{ userRepository: ur, } } // 検索 func (uu userUseCase) Search(name string) (user []*model.User, err error) { user, err = uu.userRepository.Search(name) if err != nil { return nil, err } return user, nil } |
interface層
interface層はHTTPリクエスト・レスポンスを扱う層です。
usecase層と切り離すことでリクエストやレスポンスの形に変わってもinterface層の修正だけで済むようになります。
今回はGETパラメータのnameを引数にusecase層の検索メソッドを呼び出し、返り値をJSON形式に変換しレスポンスとして返却しています。
・interface/handler/user.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 | package rest import ( "encoding/json" "net/http" "strconv" "github.com/julienschmidt/httprouter" "github.com/sample/sample-api/usecase" ) // userにおけるHandlerのインターフェース type userHandler interface { Index(http.ResponseWriter, *http.Request, httprouter.Params) } type userHandler struct { userUseCase usecase.UserUseCase } // Userデータに関するHandlerを生成 func NewUserHandler(uu usecase.UserUseCase) UserHandler { return &userHandler{ userUseCase: uu, } } // UserIndex : GET /users -> 検索結果を返す func (uh userHandler) Index(w http.ResponseWriter, r *http.Request, pr httprouter.Params) { // GETパラメータ name := r.FormValue("name") user, err := jh.userUseCase.Search(name) if err != nil { http.Error(w, err.Error(), 500) return } // クライアントにレスポンスを返却 if err = json.NewEncoder(w).Encode(user); err != nil { http.Error(w, err.Error(), 500) return } } |
main.go
main.goでは依存関係の定義とルーティングを設定します。各層の依存関係を定義することで、利用可能な状態にします。
infrastructure層でDBを操作するためconfig.Connect()でDB情報をセットしておきます。
また、サーバ起動のタイミングであわせてCORSの設定もしておきます。今回は例として「https://example.com」からGETのみ許可するようにしています。
・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 | package main import ( "fmt" "log" "net/http" "github.com/julienschmidt/httprouter" "github.com/sample/sample-api/config" "github.com/sample/sample-api/infrastructure/persistence" handler "github.com/sample/sample-api/interfaces/handler" "github.com/sample/sample-api/usecase" ) func main() { // 依存関係を定義 userPersistence := persistence.NewUserPersistence(config.Connect()) userUseCase := usecase.NewUserUseCase(userPersistence) userHandler := handler.NewUserHandler(userUseCase) // ルーティングの設定 router := httprouter.New() router.GET("/api/users", userHandler.Index) // サーバ起動 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", "https://example.com") w.Header().Set("Access-Control-Allow-Methods", "GET") 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情報を定義しておき実行環境変数から取得しています。
・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 | package config import ( "os" "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("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) } return db } // DB終了 func Close() { if err := db.Close(); err != nil { panic(err) } } |
まとめ
Go + DDD + レイヤードアーキテクチャでREST APIを実装してみました。今回のような簡単なAPIではあまり効果を発揮しないように思いますが、それぞれのレイヤと依存関係を実装しながら学ぶことでDDDとレイヤードアーキテクチャの理解を深めることができました。
WebメディアやサービスではMVCをベースで開発していましたが、アジャイル開発でどんどん仕様が変わり複雑化していく中でよりよい開発手法を取り入れたいとずっと思っていました。
そのため、今後新規開発していく上でアーキテクチャ選定のひとつの選択肢として積極的に取り入れていきたいと思います。
その後、こちらでCRUDのREST APIを実装してみました。