17
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Go 4Advent Calendar 2020

Day 9

GoではじめるHexagonal Architecture

Last updated at Posted at 2020-12-08

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

principle.png

実装コンセプト

:warning:
この先、読み進めていってClean Archtectureと何が違うのと思う方もいるかと...
正しいと思います!!
ごりごり細かく作っていくとClean Archtectureと大して変わんなかったです。

概要

定義どおり実装してしまうとCoreが膨れすぎて実装しづらいので、Netflixの記事で上がっていたように、3層構造で実装を行います。

concepts.png
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を差し替えることで参照元を切り替えられるようにします。

datasource.png

実装

構成

構成の例です。

.
├── 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のお助けパッケージです。

main.go
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して読み込めばいいだけにしました。

httphandler.go
package transport

import "github.com/go-chi/chi"

type (
	// HTTPHandler ...
	HTTPHandler interface {
		Route() *chi.Mux
	}
)
products_handler.go
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のような処理が欲しくなったときは、別パッケージにしてここの配下にしています

find.go
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
repository/product.go
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)
	}
)

registry.go
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 &registry{
		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に移行したり、スピードを求められるプロジェクトで利用する分にはこちらのほうがいいかもしれません。

17
15
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
17
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?