お題
表題の通りだけど、Goでクリーン・アーキテクチャの記事は既に山ほどある。
この記事では、そもそもクリーン・アーキテクチャで書く必要のないレベルのコード量のソースを元に、「クリーン・アーキテクチャで書いたらどんな感じになるだろう?」を確認している。
他の記事との差別ポイントは特にないけど、ひょっとしたら特定のフレームワーク(「Echo」、「Gorm」、「google/wire」」との組み合わせで書かれたものは少ないかもしれないので、そこがポイントかも。
ただし、(ソースにも書いたけど)あくまで「どんな感じになるだろう?」という書きっぷりなので、当然、プロダクションレベルに耐えられるものにはなっていない。
そうそう、実装の題材は「ある特定の”商品”(item
と命名)を”登録”し”全件参照”する機能をREST形式で提供するWebAPIサーバ」としてみた。
注釈
クリーン・アーキテクチャ自体の何たるかについての説明は、この記事には出てこない。
(先述の通り、そこから説明する記事は既にたくさんあるので)
ただ、理解のために、説明によく登場する「円」の図でなく、REST-APIが叩かれてから何がどのように呼ばれるのかの流れは図示してみる。
この記事ではっきりしているのは「このように書いたら、クリーンアーキテクチャ導入前と同様に動作した」という事実だけなので、「Goでクリーンアーキテクチャを書くとはこういうことだ!」というものには、とてもなっていない。
対象読者
GolangでWebAPIを書いたことがある人。
実装・動作確認端末
# 言語バージョン
$ go version
go version go1.11.4 linux/amd64
# IDE - Goland
GoLand 2019.2
Build #GO-192.5728.103, built on July 23, 2019
実践
要件
ある特定の「商品」(item
と命名)に対する”登録機能”と”全件取得機能”をREST-API形式で提供する。
クリーン・アーキテクチャ導入前
正直なところ、このレベルの要件ならmain
関数に書ききっても十分に収まる。
ソース全量
テーブル定義
CREATE TABLE IF NOT EXISTS `item` (
`id` varchar(64) NOT NULL,
`name` varchar(256) NOT NULL,
`price` int NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_bin;
上記のテーブルを以下にてdocker
起動し、REST-APIサービスからの接続先とする。
あくまでローカル起動用なのでDBパスワードなども直接記載。
version: '3'
services:
db:
restart: always
image: mysql:5.7.24
command: mysqld --character-set-server=utf8 --collation-server=utf8_unicode_ci
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: localuser
MYSQL_PASSWORD: localpass
MYSQL_DATABASE: localdb
volumes:
- ./persistence/init:/docker-entrypoint-initdb.d
REST-APIソース
package main
import (
_ "github.com/go-sql-driver/mysql"
"github.com/jinzhu/gorm"
"github.com/labstack/echo"
"log"
"net/http"
)
// 注意: プロダクション品質ではありません。
func main() {
/*
* DBコネクション取得等の初期セットアップ
*/
db, err := gorm.Open("mysql", "localuser:localpass@tcp(localhost:3306)/localdb?charset=utf8&parseTime=True&loc=Local")
if err != nil {
log.Fatal(err)
}
defer func() {
if db != nil {
if err := db.Close(); err != nil {
log.Fatal(err)
}
}
}()
/*
* Web-APIサーバの起動とルーティング設定
*/
e := echo.New()
// 「商品」を登録
e.POST("/item", func(c echo.Context) error {
i := &item{}
if err := c.Bind(i); err != nil {
return sendResponse(c, http.StatusBadRequest)
}
if err := db.Create(&i).Error; err != nil {
log.Println(err)
return sendResponse(c, http.StatusInternalServerError)
}
return sendResponse(c, http.StatusOK)
})
// 「商品」一覧を返却
e.GET("/item", func(c echo.Context) error {
var res []*item
if err := db.Find(&res).Error; err != nil {
log.Println(err)
return sendResponse(c, http.StatusInternalServerError)
}
return c.JSON(http.StatusOK, res)
})
e.Logger.Fatal(e.Start(":8080"))
}
// 「商品」を定義
type item struct {
ID string `json:"id" gorm:"column:id;primary_key"` // 商品ID
Name string `json:"name" gorm:"column:name"` // 商品名
Price int `json:"price" gorm:"column:price"` // 金額
}
func (i *item) TableName() string {
return "item"
}
func sendResponse(c echo.Context, code int) error {
return c.JSON(code, struct {
Message string `json:"message"`
}{Message: http.StatusText(code)})
}
Gormを使ってローカルDocker起動のMySQLにコネクション張って、Echoを使ってWebAPIサーバとして起動。
e.POST(~~)
やe.GET(~~)
として商品の登録・一覧表示機能用のエンドポイントを実装。ただそれだけ。
動作確認
以下で起動。
$ go run main.go
go: downloading github.com/jinzhu/gorm v1.9.10
go: downloading github.com/jinzhu/inflection v1.0.0
____ __
/ __/___/ / ___
/ _// __/ _ \/ _ \
/___/\__/_//_/\___/ v3.3.10-dev
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
O\
⇨ http server started on [::]:8080
DBの初期状態をチェック。(「DBeaver」というツール)
「Postman」を使って、”商品登録”を3回実行。
”商品一覧”を実行すると、登録された3件分のJSONが表示される。
「商品」(item
)テーブルにもレコードが登録されていることが確認できる。
クリーン・アーキテクチャ導入後
「冗談でしょ?」というくらいの構造になった。
こうなると、初見で「商品登録機能」の実装を確認しようとして、まずどのソースを見ればいいかわからないのでは?
「MVC」の概念に馴染みがある人なら、まずコントローラーが起点になるはずと考えて、「/adapter/controller/item.go
」に行き着くかもしれない。
ただ、RESTのエンドポイント実装としては「handler
」という名前も使われたりするから、そっちで実装してきた人は、もう説明がないと厳しい。
(もちろん、このくらいの機能なら片っ端からパッケージ毎に1ファイルずつ確認していけばいいのだけど)
(あと、go
であれば、よほどな実装になっていなければmain
パッケージのmain.go
から辿って迷子になることはないと思うけど)
ともあれ、クリーンアーキテクチャ導入前であればmain.go
の中の50stepくらい読んだら全て理解できたものが、クリーンアーキテクチャ導入後は「どこから見たらいいかわからない」状態になった。
もちろん、この構造にすることで、(とりわけリリース後に)機能を追加、変更、削除しようとした時に影響箇所を極小化できるのだけど、その分、イニシャル「理解」コストは格段に上がったね。。。
パッケージ構成
$ tree
.
├── adapter
│ ├── controller
│ │ ├── helper.go
│ │ └── item.go
│ ├── gateway
│ │ └── item.go
│ └── presenter
│ ├── helper.go
│ └── item.go
├── domain
│ ├── item.go
│ ├── model
│ │ └── item.go
│ └── repository
│ └── item.go
├── driver
│ └── db.go
├── go.mod
├── go.sum
├── main.go
├── README.md
├── usecase
│ ├── item.go
│ ├── model
│ │ └── item.go
│ └── outputport
│ └── item.go
├── wire_gen.go
└── wire.go
11 directories, 18 files
呼び出し+interface実装構造
関係
kazukousen
さんのgouml
を使って自動生成したUMLクラス図のクラス種別だけを修正したもの
https://github.com/kazukousen/gouml
kazukousen
さん、ありがとう。
シーケンス
interfaceの実装と呼び出しは図に出来たものの、それでもこれだけだとまだ理解は難しいと思う。
やっぱり、HTTPクライアントから「商品を登録」する機能を叩いたところから、どういう順番でどのファイルの何が呼ばれるのかが流れで図示されてないと厳しいか。。。
というわけで、シーケンス図も作ってみた。(by PlantUML)
※完全に厳密にパッケージ構成、ソースコードの内容と合致しているわけではないけど、おおよそ上記のイメージ
上記と↓のUncle Bobの記事にある「円」の図を比較すると少しは理解度が上がる・・・はず。(・・・結局、「円」を持ち出してしまった・・・)
https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
簡易説明
1.クライアント(上の図では「Alice
」から「商品登録」のHTTPリクエストが飛んでくると、
2.まずは”実装詳細”であるHTTPリクエスト受け付け口である「Frameworks & Drivers
」層で受け付ける。
(ここは正にシステム外との境界なので、”実装詳細”からは逃れられない。だって、クライアントからHTTP通信で来るのかgRPC通信で来るのか、はたまたファイル送りつけられるのか、そこがはっきりしてないとリクエスト受け取れない。)
3.で、その名の通り外界と内界とのアダプターである「Interface Adapters
」層で、外界からのリクエストを内界で処理できるように変換し、いざ、内界へ。
4〜6.「Application Business Rules
」層は、いわゆるユースケースを扱う領域・・・のはず。
基本的には、より内界の「Enterprise Business Rules
」層のロジックを組み合わせてユースケースを実現し、結果を(アダプターによる変換、そして、”実装詳細”の層を経て)クライアントに返却する。
7〜8.「Enterprise Business Rules
」層で”リポジトリー”なるものを呼んで「商品」というモデルに対する処理を担わせる。
(ポイントとしては、ここから先は情報の永続化に関して”実装詳細”が必要になる領域なので、「Enterprise Business Rules
」層として呼び出すのは、あくまでインタフェース。具体的なロジックは次の「Interface Adapters
」層で実装する。)
9〜10.処理1〜2あたりの逆で、ビジネスロジックを経た結果を今度はシステムの外界にて処理するための流れ。最終的にRDBにて永続化。
(この”層”の分離があると、永続化方法をRDBから変えたいといったときに、ビジネスロジックが書かれたソースをいじらずに切り替え可能、、、のはず)
11〜14.処理結果を、再び各層を経て、クライアントに返却する。
ソース全量
”商品”を登録する機能
先述のシーケンス図と見比べながらなら、ぎりぎり理解できるだろうか。。。
いや、自分でも最初は書きながら迷子になっていたから、やっぱりこれだけ層を分けて初見で理解って難しいんじゃ・・・。
(そもそもクリーンアーキテクチャとしてちゃんとした書き方になっているかも確信がないし)
package controller
import (
"github.com/labstack/echo"
"go-ca-webapi/02_cleanarchitecture/adapter/presenter"
"go-ca-webapi/02_cleanarchitecture/usecase"
)
func NewItem(e *echo.Echo, input usecase.Item) Item {
return &itemController{
e: e,
input: input,
}
}
type Item interface {
Handle()
}
type itemController struct {
e *echo.Echo
input usecase.Item
}
func (i *itemController) Handle() {
i.e.POST("/item", i.saveItem)
}
// 「商品」を登録
func (i *itemController) saveItem(c echo.Context) error {
o := presenter.NewItem(c)
r := &saveItemRequest{}
if err := c.Bind(r); err != nil {
return o.RenderFailure(err)
}
return i.input.SaveItem(convertFrom(r), o)
}
// JSON形式のHTTPリクエストBodyパース用
type saveItemRequest struct {
Name string `json:"name"` // 商品名
Price int `json:"price"` // 金額
}
// HTTPリクエストをusecase層に渡すための変換
func convertFrom(r *saveItemRequest) *usecase.SaveItemRequest {
return &usecase.SaveItemRequest{
ID: generateID(),
Name: r.Name,
Price: r.Price,
}
}
package usecase
import (
"go-ca-webapi/02_cleanarchitecture/domain"
"go-ca-webapi/02_cleanarchitecture/domain/model"
usecasemodel "go-ca-webapi/02_cleanarchitecture/usecase/model"
"go-ca-webapi/02_cleanarchitecture/usecase/outputport"
)
func NewItem(itemDomain domain.Item) Item {
return &item{itemDomain: itemDomain}
}
// adapter/controller層から呼ばれるインプットポート
type Item interface {
SaveItem(r *SaveItemRequest, o outputport.ItemOutputPort) error
}
type item struct {
itemDomain domain.Item
}
func (i *item) SaveItem(r *SaveItemRequest, o outputport.ItemOutputPort) error {
err := i.itemDomain.SaveItem(convertFrom(r))
if err == nil {
return o.RenderSaveResult(&usecasemodel.SaveItem{ID: r.ID})
} else {
return o.RenderFailure(err)
}
}
type SaveItemRequest struct {
ID string // 商品ID
Name string // 商品名
Price int // 金額
}
func convertFrom(r *SaveItemRequest) *model.Item {
return &model.Item{
ID: r.ID,
Name: r.Name,
Price: r.Price,
}
}
package domain
import (
"go-ca-webapi/02_cleanarchitecture/domain/model"
"go-ca-webapi/02_cleanarchitecture/domain/repository"
)
func NewItem(itemRepository repository.Item) Item {
return &item{itemRepository: itemRepository}
}
type Item interface {
SaveItem(m *model.Item) error
}
type item struct {
itemRepository repository.Item
}
func (i *item) SaveItem(m *model.Item) error {
return i.itemRepository.SaveItem(m)
}
package repository
import (
"go-ca-webapi/02_cleanarchitecture/domain/model"
)
type Item interface {
SaveItem(m *model.Item) error
}
package gateway
import (
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
"go-ca-webapi/02_cleanarchitecture/domain/model"
"go-ca-webapi/02_cleanarchitecture/domain/repository"
)
func NewItem(dbConn *gorm.DB) repository.Item {
return &ItemRepository{dbConn: dbConn}
}
// entity.repository層の実装
type ItemRepository struct {
dbConn *gorm.DB
}
func (i *ItemRepository) SaveItem(m *model.Item) error {
if err := i.dbConn.Create(convertFrom(m)).Error; err != nil {
return errors.Wrap(err, "@repository.itemRepository#SaveItem()")
}
return nil
}
type itemRecord struct {
ID string `gorm:"column:id;primary_key"` // 商品ID
Name string `gorm:"column:name"` // 商品名
Price int `gorm:"column:price"` // 金額
}
// O/RマッパーにGormを使う上で必要となる「テーブル名」のマッピング
func (i *itemRecord) TableName() string {
return "item"
}
func (i *itemRecord) convertToModel() *model.Item {
return &model.Item{
ID: i.ID,
Name: i.Name,
Price: i.Price,
}
}
// entity層のモデルをGorm依存のモデルにマッピング
func convertFrom(itemModel *model.Item) *itemRecord {
return &itemRecord{
ID: itemModel.ID,
Name: itemModel.Name,
Price: itemModel.Price,
}
}
package outputport
import (
usecasemodel "go-ca-webapi/02_cleanarchitecture/usecase/model"
)
type ItemOutputPort interface {
RenderSaveResult(target *usecasemodel.SaveItem) error
RenderFailure(err error) error
}
package presenter
import (
"github.com/labstack/echo"
usecasemodel "go-ca-webapi/02_cleanarchitecture/usecase/model"
"go-ca-webapi/02_cleanarchitecture/usecase/outputport"
"net/http"
)
func NewItem(c echo.Context) outputport.ItemOutputPort {
return &itemPresenter{c: c}
}
type itemPresenter struct {
c echo.Context
}
func (i itemPresenter) RenderSaveResult(target *usecasemodel.SaveItem) error {
return i.c.JSON(http.StatusOK, convertFromSaveItem(target))
}
func (i itemPresenter) RenderFailure(err error) error {
return sendResponse(i.c, http.StatusInternalServerError)
}
// JSON形式のHTTPレスポンスBody生成用
type saveItemResponse struct {
ID string `json:"id"` // ID
}
// HTTPリクエストをクライアントに渡すための変換
func convertFromSaveItem(r *usecasemodel.SaveItem) *saveItemResponse {
return &saveItemResponse{ID: r.ID,}
}
アプリ起動からWebAPIサーバーとしてのセッティングまで
クリーンアーキテクチャの説明としては本筋から外れるけど、サービス作ろうと思ったらここの実装は必要になるので、一応、ソース載せる。
今回はGoogle製のwireを使ってDIした。そもそもそれがどんなものかは下記参照。
https://qiita.com/sky0621/items/a94d8331dfe35781cdd1
package main
import (
_ "github.com/go-sql-driver/mysql"
"github.com/labstack/echo"
"go-ca-webapi/02_cleanarchitecture/adapter/controller"
"go-ca-webapi/02_cleanarchitecture/usecase"
"go-ca-webapi/02_cleanarchitecture/driver"
"log"
"os"
)
// 注意: プロダクション品質ではありません。
func main() {
/*
* DBコネクション取得等の初期セットアップ
*/
dbConn, closeFunc, err := driver.NewDBConnection(
os.Getenv("PRJ_GROWUP_USERNAME"),
os.Getenv("PRJ_GROWUP_PASSWORD"),
os.Getenv("PRJ_GROWUP_INSTANCE"),
os.Getenv("PRJ_GROWUP_DBNAME"))
if err != nil {
log.Fatal(err)
}
defer closeFunc()
app := Initialize(dbConn, echo.New())
app.Start()
}
func NewApp(
e *echo.Echo,
itemUsecase usecase.Item,
itemController controller.Item,
) App {
return App{
e: e,
itemUsecase: itemUsecase,
itemController: itemController,
}
}
type App struct {
e *echo.Echo
itemUsecase usecase.Item
itemController controller.Item
}
func (a App) Start() {
a.itemController.Handle()
a.e.Logger.Fatal(a.e.Start(":8080"))
}
//+build wireinject
package main
import (
"context"
"github.com/google/wire"
"github.com/jinzhu/gorm"
"github.com/labstack/echo"
"go-ca-webapi/02_cleanarchitecture/adapter/controller"
"go-ca-webapi/02_cleanarchitecture/adapter/gateway"
"go-ca-webapi/02_cleanarchitecture/domain"
"go-ca-webapi/02_cleanarchitecture/usecase"
)
var superSet = wire.NewSet(
gateway.NewItem,
controller.NewItem,
domain.NewItem,
usecase.NewItem,
NewApp,
)
func Initialize(ctx context.Context, dbConn *gorm.DB, e *echo.Echo) App {
wire.Build(superSet)
return App{}
}
// Code generated by Wire. DO NOT EDIT.
//go:generate wire
//+build !wireinject
package main
import (
"github.com/google/wire"
"github.com/jinzhu/gorm"
"github.com/labstack/echo"
"go-ca-webapi/02_cleanarchitecture/adapter/controller"
"go-ca-webapi/02_cleanarchitecture/adapter/gateway"
"go-ca-webapi/02_cleanarchitecture/domain"
"go-ca-webapi/02_cleanarchitecture/usecase"
)
import (
_ "github.com/go-sql-driver/mysql"
)
// Injectors from wire.go:
func Initialize(dbConn *gorm.DB, e *echo.Echo) App {
item := gateway.NewItem(dbConn)
domainItem := domain.NewItem(item)
usecaseItem := usecase.NewItem(domainItem)
controllerItem := controller.NewItem(e, usecaseItem)
app := NewApp(e, usecaseItem, controllerItem)
return app
}
// wire.go:
var superSet = wire.NewSet(gateway.NewItem, controller.NewItem, domain.NewItem, usecase.NewItem, NewApp)
package driver
import (
"fmt"
"github.com/jinzhu/gorm"
"log"
_ "github.com/go-sql-driver/mysql"
)
// DBコネクションクローズ用の関数
type closeDBConnectionFunc func()
// RDB(MySQL)コネクション取得
func NewDBConnection(user, pass, host, db string) (*gorm.DB, closeDBConnectionFunc, error) {
connStr := "%s:%s@tcp(%s)/%s?charset=utf8&parseTime=True&loc=Local"
dbConn, err := gorm.Open("mysql", fmt.Sprintf(connStr, user, pass, host, db))
return dbConn, func() {
if dbConn != nil {
if err := dbConn.Close(); err != nil {
log.Fatal(err)
}
}
}, err
}
まとめ
正直なところ、いきなりクリーンアーキテクチャから始めても理解するのは難しい気がする。
自分の場合は、もともとJavaの世界にいて「MVC」という考え方でレイヤーを分けていく考えは理解していたので、その延長線上の話かと思い込んでクリーンアーキテクチャも理解しようとしていた。
のだけど、それだけだとダメで、DIP(依存性逆転の原則)なんかも理解してないと、パッケージ構成を考えた時に、「どこにインタフェースでどこにstruct置くんだっけ?」となる。
ビジネスロジックと実装詳細を分ける部分や、それを「ポート」と「アダプター」といった概念を使って実装に落としていく手法や、とにかく前提として理解しておくことが多い。
※たぶん、ただクリーンアーキテクチャぽく書くだけなら浅い理解でもそれなりに(自分が書いたソースだって深い理解に基づいていないし)できるのだけど。
今回のは、例えばプロダクションレベルのコードを書く時に頻出の「トランザクション」をどこに持ち込むか?とか、
”ログ”、”異常検知”、(RDBアクセスは書いたけど)”キャッシュ”、etc... といったもろもろが一切ない状態のコード。
(そして、テストコードも書いてないので、テスタブルかどうかも未検証)
これらを持ち込んだ時に、クリーンな構造を維持しつつ機能追加・改修が容易な構造にできるのか。
次は、1サービス作るくらいの勢いでクリーンアーキテクチャ取り入れてみないとダメかな。