はじめに
Goの勉強も兼ねて、Hexagonal Architectureについてまとめてみました。
他の方がすでにとても詳しい記事を書かれていたのですが、理解のために自分なりのアウトプットをしてみました。
Hexagonal Architecture
Hexagonal Architectureは、ドメインロジックを中心に置き、ポート(Ports)経由で外界(DB、外部API、メッセージングサービス、コマンドラインインターフェースなど)とつながるようなアーキテクチャのことをいいます。
ポートはPrimary portsとSecondary portsの2種類に区別されます。
- Primary ports:システムを駆動する(例:API、コマンドラインのようなインターフェース)
- Secondary ports:システムによって駆動する(例:DB、メッセージングサービス)
ドメインロジックと外界をつなぐためには、ポートだけではなくアダプタ(Adapters)というものも必要となります。
Hexagonal Architectureでは「外界→ドメイン」のように外側から内側への依存関係にするのが定石なのですが、Secondary ports側をそのまま実装しようとすると、「ドメイン→外界」という内側から外側への依存関係になってしまいます。
このような関係を回避するため、ドメインと外界の間にアダプタを置くことで、依存関係を正しい方向に逆転させてあげます(Dependency inversion)。
アダプタを置くことで、ドメイン側はデータソースがDBかスタブかどうかなどを意識することなく、データ操作を行うことができます(Repository pattern)。
また、フレームワークやライブラリについては、ドメインに依存しないようにアダプタ内で独立させるようにします。
実装
Hexagonal Architectureでbanking-practice
というプロジェクトをつくります。
DBとスタブを用意して、データソースを簡単に切り替えられることを確認します。
登場人物を並べると以下のようになります。
アダプタ
- REST Handlers(ユーザ側)
- Mock Adapter(サーバ側)
- Database Adapter(サーバ側)
ポート
- Service(Primary ports)
- Repository(Secondary ports)
また、プロジェクトのディレクトリ構成は以下のようになります。
Repositoryの実装
まずはdomain/customer.go
に取得するデータであるCustomerの構造体(Structs)とCustomerのInterfaceを定義します。
今回、スタブとDBからデータを取得するため、Repositoryは2つ必要です。
package domain
type Customer struct {
Id string
Name string
City string
Zipcode string
DateofBirth string
Status string
}
type CustomerRepository interface {
FindAll() ([]Customer, error)
}
スタブ側のRepositoryは以下のようになります。
NewCustomerRepositoryStub()
は自分で用意したcustomersを返します。
また、FindAll()
メソッドもレシーバのcustomersをそのまま返します。
package domain
type CustomerRepositoryStub struct {
customers []Customer
}
func (s CustomerRepositoryStub) FindAll() ([]Customer, error) {
return s.customers, nil
}
func NewCustomerRepositoryStub() CustomerRepositoryStub {
customers := []Customer {
{"1001", "Yamada", "Kanagawa", "1110000", "1970-01-01", "1"},
{"1002", "Akiyama", "Akita", "2220000", "1960-01-01", "2"},
}
return CustomerRepositoryStub{customers: customers}
}
DB側のRepositoryは以下のようになります。
NewCustomerRepositoryDb()
でDB(今回はMySQL)から取得したデータを返します。
FindAll()
メソッドでは、DBから取得したデータからcustomersを返すような実装を行っています。
d.client.Query(findAllSql)
でクエリを実行してcustomersテーブルのデータを取得した後に、forループでcustomersにデータを挿入していきます。
package domain
import (
"database/sql"
"log"
"time"
_ "github.com/go-sql-driver/mysql"
)
type CustomerRepositoryDb struct {
client *sql.DB
}
func (d CustomerRepositoryDb) FindAll() ([]Customer, error) {
findAllSql := "select customer_id, name, city, zipcode, date_of_birth, status from customers"
rows, err := d.client.Query(findAllSql)
if err != nil {
log.Println("Error while querying customer table " + err.Error())
return nil, err
}
customers := make([]Customer, 0)
for rows.Next() {
var c Customer
err := rows.Scan(&c.Id, &c.Name, &c.City, &c.Zipcode, &c.DateofBirth, &c.Status)
if err != nil {
log.Println("Error while scanning customers " + err.Error())
return nil, err
}
customers = append(customers, c)
}
return customers, nil
}
func NewCustomerRepositoryDb() CustomerRepositoryDb {
client, err := sql.Open("mysql", "root:password@tcp(localhost:3306)/banking_practice")
if err != nil {
panic(err)
}
client.SetConnMaxLifetime(time.Minute * 3)
client.SetMaxOpenConns(10)
client.SetMaxIdleConns(10)
return CustomerRepositoryDb{client}
}
Serviceの実装
CustomerRepository
を型にもつフィールドrepo
をDefaultCustomerService
の構造体で定義します。
DefaultCustomerService
を定義することで、Repositoryの実装に関わらず、GetAllCustomer
やNewCustomerService
を利用することができます(CustomerRepositoryStub
とCustomerRepositoryDb
のどちらも引数にとることができます)。
package service
import (
"github.com/suzuki0430/banking-practice/domain"
)
type CustomerService interface {
GetAllCustomer() ([]domain.Customer, error)
}
type DefaultCustomerService struct {
repo domain.CustomerRepository
}
func (s DefaultCustomerService) GetAllCustomer() ([]domain.Customer, error) {
return s.repo.FindAll()
}
func NewCustomerService(repository domain.CustomerRepository) DefaultCustomerService {
return DefaultCustomerService{repo: repository}
}
Handlerの実装
HTTPリクエストに応じた処理(Handler)として、getAllCustomers
を実装します。
リクエストヘッダーのContent-Typeによって、出力形式もエンコードされるようにします。
package app
import (
"encoding/json"
"encoding/xml"
"net/http"
"github.com/gorilla/mux"
"github.com/suzuki0430/banking-practice/service"
)
type CustomerHandlers struct {
service service.CustomerService
}
func (ch *CustomerHandlers) getAllCustomers(w http.ResponseWriter, r *http.Request) {
customers, _ := ch.service.GetAllCustomer()
if r.Header.Get("Content-Type") == "application/xml" {
w.Header().Add("Content-Type", "application/xml")
xml.NewEncoder(w).Encode(customers)
} else {
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(customers)
}
}
マルチプレクサ(どのURLにどんな処理をさせるかを設定できるもの)の実装にはgorilla/mux
を利用し、ルートをrouter.HandleFunc("/customers", ch.getAllCustomers).Methods(http.MethodGet)
のように定義しています。
CustomerHandler
の中のRepositoryを切り替えることで、接続先をスタブかDBに設定することができます。
package app
import (
"log"
"net/http"
"github.com/gorilla/mux"
"github.com/suzuki0430/banking-practice/domain"
"github.com/suzuki0430/banking-practice/service"
)
func Start() {
router := mux.NewRouter()
// wiring Stub
// ch := CustomerHandlers{service: service.NewCustomerService(domain.NewCustomerRepositoryStub())}
// wiring Db
ch := CustomerHandlers{service: service.NewCustomerService(domain.NewCustomerRepositoryDb())}
// define routes
router.HandleFunc("/customers", ch.getAllCustomers).Methods(http.MethodGet)
// starting server
log.Fatal(http.ListenAndServe("localhost:8000", router))
}
go run main.go
でローカルサーバをたてて、localhost:8000/customers
にアクセスすることでデータを取得することができます。
参考資料