Hexagonal Architecture とは
Clean Architectureなどのような、Onion Architectureの一種です。
最近だと、Netflixで採用されています。
基本的には、AdapterとCoreの2層のみで、UIやAPI Interfaceなどが、Adapterでそれ以外のApplicationの処理をCoreが担います。
Hexagonal(六角形)というのはAdapterでIN/OUTするポート(Web API、ファイル入出力など)が6種類別々に分けられているところから由来しているらしいですが、現在だといろんなIN/OUTの方法があるので、概略図とか見ても6つで収まってないですw
実装コンセプト
この先、読み進めていってClean Archtectureと何が違うのと思う方もいるかと...
正しいと思います!!
ごりごり細かく作っていくとClean Archtectureと大して変わんなかったです。
概要
定義どおり実装してしまうとCoreが膨れすぎて実装しづらいので、Netflixの記事で上がっていたように、3層構造で実装を行います。
via Netflix
TRANSPORT LAYER
Adapterと同じものです。
HTTP Request & Responseなどを管理し、Applicationにデータを渡したり、Applicationから渡ってきたデータをもとにリクエストにあったレスポンスを作成して返したりします。
Frameworkの処理はなるべくここで行うようにし、Core層に侵食しないようにします。
これはFrameworkを切り替えたときなどにCoreに影響を及ぼさないようにするためです。
INTERACTORS & ENTITIES
Coreは、INTERACTORSとENTITIESに分け、ENTITIESにはビジネスロジックを、Usecaseなどそれ以外をINTERACTORSに追加していきます。
そこまで、細かい構成ではないのでINTERACTORSとENTITIESのどちらに書けばいいか迷ったらとりあえずINTERACTORSによせて後々チームのメンバーと相談してどちらに寄せるか決めるといいと思います。
DATA SOURCES & REPOSITORIES
REPOSITORIESでInterfaceを用意して抽象化し、DATA SOURCEを差し替えることで参照元を切り替えられるようにします。
実装
構成
構成の例です。
.
├── internal
│ ├── datasource
│ │ ├── httpapi
│ │ ├── mysql
│ │ └── registry.go
│ ├── domain
│ │ ├── credential
│ │ ├── entity
│ │ └── validation
│ ├── errors
│ ├── infrastructure
│ │ └── infrastructure.go
│ ├── interactor
│ │ ├── service
│ │ └── usecase
│ │ ├── products
│ │ │ ├── create.go
│ │ │ ├── delete.go
│ │ │ ├── find.go
│ │ │ ├── search.go
│ │ │ └── update.go
│ │ └── makers
│ │ ├── create.go
│ │ ├── delete.go
│ │ ├── find.go
│ │ ├── search.go
│ │ └── update.go
│ ├── repository
│ └── transport
│ ├── httphandler
│ │ ├── products_handler.go
│ │ └── makers_handler.go
│ └── httphandler.go
├── main.go
└── misc
└── db
└── migrations
SERVER
main.go
にサーバのコードを記載しています。
internal
にそれ以外の処理を閉じ込めていますが、micro servicesにした際にモノレポにしているとIDEやEditorの機能でimportの箇所に別サービスのパッケージを持ってきてしまうことがありますが、internalにしていると自分のパッケージしか参照できないので、混入を気づけるようにするためのものです。
サーバの実装例は以下のとおりです。
routerにgo-chiを使っていて、transportのパッケージを読み込めばいいだけのかんたんな設計にしています。
usk81/go-auraは、何度も同じような処理を書くのが面倒だったので、独自に作成したchiのお助けパッケージです。
package main
import (
"flag"
"net"
"net/http"
"time"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/usk81/go-aura/router"
"go.uber.org/zap"
"github.com/usk81/xxxx/xxxx/internal/datasource"
"github.com/usk81/xxxx/xxxx/internal/datasource/mysql"
"github.com/usk81/xxxx/xxxx/internal/transport/httphandler"
smw "github.com/usk81/xxxx/shared/infrastructure/middleware"
"github.com/usk81/xxxx/shared/infrastructure/server"
)
const (
location = `Asia/Tokyo`
)
func init() {
loc, err := time.LoadLocation(location)
if err != nil {
loc = time.FixedZone(location, 9*60*60)
}
time.Local = loc
}
func main() {
var listenAddr string
flag.StringVar(&listenAddr, "listen-addr", ":80", "server listen address")
flag.Parse()
logger, _ := smw.BuildLogger()
defer func() {
_ = logger.Sync()
}()
logger.Info("Configure API")
r := newRouter(logger)
srv, err := server.New(listenAddr, logger, r)
if err != nil {
panic(err)
}
srv.Start()
}
func newRouter(logger *zap.Logger) *chi.Mux {
mux := router.Setup(
middleware.RequestID,
middleware.RealIP,
smw.Logger(logger),
middleware.Recoverer,
)
// create database connections
dr := mysql.ConnectReader()
dw := mysql.ConnectWriter()
rg := datasource.NewRegistry(&http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
Timeout: 60 * time.Second,
}, dr, dw)
// register more routes over here...
httphandler.NewProducts(rg).Route(mux)
httphandler.NewMakers(rg).Route(mux)
router.LogRoutes(mux, logger)
return mux
}
TRANSPORT LAYER
Interfaceの種類を複数定義して差し替えれるようにしています。
(gRPCだと流石にサーバから書き換えになりそうですが、GraphQLとかだったら大丈夫かと)
transport
├── httphandler
│ ├── products_handler.go
│ └── makers_handler.go
└── httphandler.go
ルーティングは、よくサーバの実行ファイルに追加してくパターンをWeb Frameworkの実装例で見ますがそうすると、サーバのファイルが汚くなるので、HTTP HandlerのInterfaceにRoutingを出力する処理をつくって、各Handlerのファイルで管理するようにして、サーバはただそれをAppendして読み込めばいいだけにしました。
package transport
import "github.com/go-chi/chi"
type (
// HTTPHandler ...
HTTPHandler interface {
Route() *chi.Mux
}
)
package httphandler
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/go-chi/chi"
"github.com/usk81/go-aura/router"
"github.com/usk81/xxxxx/xxxxx/products/internal/domain/entity"
"github.com/usk81/xxxxx/xxxxx/products/internal/errors"
usecase "github.com/usk81/xxxxx/xxxxx/products/internal/interactor/usecase/products"
"github.com/usk81/xxxxx/xxxxx/products/internal/repository"
se "github.com/usk81/xxxxx/shared/errors"
"github.com/usk81/xxxxx/shared/logger"
"github.com/usk81/xxxxx/shared/transport/render"
)
type (
// ProductsHandler :
ProductsHandler struct {
registry repository.Registry
}
)
// NewProducts ...
func NewProducts(registry repository.Registry) *ProductsHandler {
return &ProductsHandler{
registry: registry,
}
}
// Route sets endpoint routing
func (h *ProductsHandler) Route(mux *chi.Mux) (err error) {
routes := router.Route{
Endpoints: []router.EndpointPattern{
{
Pattern: "/products",
Endpoints: map[string]router.Endpoint{
http.MethodGet: {
Handler: h.Index,
},
http.MethodPost: {
Handler: h.Create,
},
},
},
{
Pattern: "/product/{productID}",
Endpoints: map[string]router.Endpoint{
http.MethodGet: {
Handler: h.Show,
},
http.MethodPut: {
Handler: h.Update,
},
http.MethodDelete: {
Handler: h.Delete,
},
},
},
},
}
rt := router.New(routes)
return rt.Build(mux)
}
// Index ...
func (h *ProductsHandler) Index(w http.ResponseWriter, r *http.Request) {
var req entity.SearchRequest
if err := h.bind(r.Body, &req); err != nil {
h.renderError(w, err)
return
}
result, err := usecase.Search(h.registry).Do(r.Context(), req)
if err != nil {
h.renderError(w, err)
return
}
h.renderJSON(w, http.StatusOK, result)
}
// Show ...
func (h *ProductsHandler) Show(w http.ResponseWriter, r *http.Request) {
result, err := usecase.Find(h.registry).Do(r.Context(), chi.URLParam(r, "productID"))
if err != nil {
h.renderError(w, err)
return
}
h.renderJSON(w, http.StatusOK, result)
}
// Create ...
func (h *ProductsHandler) Create(w http.ResponseWriter, r *http.Request) {
var req entity.Product
if err := h.bind(r.Body, &req); err != nil {
h.renderError(w, err)
return
}
logger.Info("Create(w http.ResponseWriter, r *http.Request)")
result, err := usecase.Create(h.registry).Do(r.Context(), req)
if err != nil {
h.renderError(w, err)
return
}
h.renderJSON(w, http.StatusOK, result)
}
// Update ...
func (h *ProductsHandler) Update(w http.ResponseWriter, r *http.Request) {
var req entity.Product
if err := h.bind(r.Body, &req); err != nil {
h.renderError(w, err)
return
}
req.ID = chi.URLParam(r, "productID")
result, err := usecase.Update(h.registry).Do(r.Context(), req)
if err != nil {
h.renderError(w, err)
return
}
h.renderJSON(w, http.StatusOK, result)
}
// Delete ...
func (h *ProductsHandler) Delete(w http.ResponseWriter, r *http.Request) {
if err := usecase.Delete(h.registry).Do(r.Context(), chi.URLParam(r, "productID")); err != nil {
h.renderError(w, err)
return
}
h.renderJSON(w, http.StatusOK, nil)
}
// SearchOrigin ...
func (h *ProductsHandler) SearchOrigin(w http.ResponseWriter, r *http.Request) {
var req entity.SearchRequest
if err := h.bind(r.Body, &req); err != nil {
h.renderError(w, err)
return
}
result, err := usecase.OriginSearch(h.registry).Do(r.Context(), req)
if err != nil {
h.renderError(w, err)
return
}
h.renderJSON(w, http.StatusOK, result)
}
func (h *ProductsHandler) bind(body io.ReadCloser, v interface{}) (err error) {
if err := json.NewDecoder(body).Decode(v); err != nil {
return errors.NewCause(err, errors.CaseBadRequest)
}
return nil
}
func (h *ProductsHandler) renderJSON(w http.ResponseWriter, s int, v interface{}) {
if err := render.JSON(w, s, v); err != nil {
err := errors.NewCause(fmt.Errorf("fail to encode response : %w", err), errors.CaseBackendError)
render.JSON(w, http.StatusInternalServerError, err) // nolint: errcheck
return
}
}
func (h *ProductsHandler) renderError(w http.ResponseWriter, err error) {
ec, ok := err.(*se.Cause)
if !ok {
ec = errors.NewCause(err, errors.CaseBackendError).(*se.Cause)
}
render.JSON(w, ec.Code(), ec) // nolint: errcheck
}
INTERACTORS
usecaseなどはこちらに追加します。
usecaseで複数の処理をとりまとめたhelperやserviceのような処理が欲しくなったときは、別パッケージにしてここの配下にしています
package products
import (
"context"
"fmt"
"github.com/usk81/xxxxx/xxxxx/products/internal/domain/entity"
"github.com/usk81/xxxxx/xxxxx/products/internal/errors"
"github.com/usk81/xxxxx/xxxxx/products/internal/interactor/service"
"github.com/usk81/xxxxx/xxxxx/products/internal/repository"
)
type (
// Finder is the usecase interface to get a product data
Finder interface {
NoCache(ctx context.Context, id string) (result *entity.Product, err error)
Do(ctx context.Context, id string) (result *entity.Product, err error)
}
findImpl struct {
product repository.ProductsFinder
maker repository.MakerFinder
}
)
// Find ...
func Find(rg repository.Registry) Finder {
return &findImpl{
product: rg.Products(),
maker: rg.Makers(),
}
}
// Do ...
func (u *findImpl) Do(ctx context.Context, id string) (result *entity.Product, err error) {
result, err = service.ProductFind(
u.product,
u.maker,
).Do(id)
if err != nil {
if err == repository.ErrNotExist {
err = errors.NewCause(err, errors.CaseNotFound)
return nil, err
}
return nil, err
}
return
}
ENTITIES
データのStructsなどなので、省略します。
独自のバリデーションパターンもこちらに追加しています。
DATA SOURCES & REPOSITORIES
RepositoryでInterfaceを定義し、各DatasourceでそのMethodに合わせた処理を用意します。
これだけだと、RepositoryとDatasourceをつなぐもの(どのDatasourceを利用するかの判断)がないので、それをregistryで行います。
├── datasource
│ ├── httpapi
│ ├── mysql
│ └── registry.go
├── repository
package repository
import (
"time"
"github.com/usk81/xxxxx/xxxxx/products/internal/domain/entity"
)
type (
// ProductsReader is interface
ProductsReader interface {
ProductsFinder
ProductsCounter
ProductsSearcher
}
// ProductsWriter is interface
ProductsWriter interface {
ProductsReader
ProductsCreater
ProductsUpdater
ProductsDeleter
}
// ProductsFinder ...
ProductsFinder interface {
Find(id string) (result *entity.Product, err error)
}
// ProductsSearcher ...
ProductsSearcher interface {
Search(ps []SearchParameter, ss []SortParameter, limit, offset int) (result []entity.Product, err error)
}
// ProductsCounter ...
ProductsCounter interface {
Count(ps []SearchParameter) (result int, err error)
}
// ProductsCreater ...
ProductsCreater interface {
ProductsFinder
ProductsSearcher
Create(req entity.Product) (err error)
}
// ProductsUpdater ...
ProductsUpdater interface {
ProductsFinder
Update(req entity.Product) (err error)
}
// ProductsDeleter ...
ProductsDeleter interface {
Delete(id string, deletedAt *time.Time) (err error)
}
)
package datasource
import (
"database/sql"
"net/http"
"github.com/usk81/xxxxx/xxxxx/products/internal/datasource/httpapi"
"github.com/usk81/xxxxx/xxxxx/products/internal/datasource/mysql"
"github.com/usk81/xxxxx/xxxxx/products/internal/repository"
)
type (
registry struct {
client *http.Client
readerDB *sql.DB
writerDB *sql.DB
}
)
// NewRegistry ...
func NewRegistry(c *http.Client, r, w *sql.DB) repository.Registry {
return ®istry{
client: c,
readerDB: r,
writerDB: w,
}
}
// ProductsReader ...
func (r *registry) ProductsReader() repository.ProductsReader {
return mysql.NewProductsReader(r.readerDB)
}
// ProductsWriter ...
func (r *registry) ProductsWriter() repository.ProductsWriter {
return mysql.NewProductsWriter(r.writerDB)
}
// 以下省略
まとめ
Clean Archtectureの利用経験もあったので、Privateなプロダクトを作る際に使ってみましたが、Goで作る限りにおいては結果としてはClean Archtectureと大差はありませんでした。
ただ、他のOnion Archtectureと比較して程よいゆるさがあるので、MVCから依存関係を切り分けたArchtectureに移行したり、スピードを求められるプロジェクトで利用する分にはこちらのほうがいいかもしれません。