LoginSignup
20
19

More than 3 years have passed since last update.

Clean Architecture by Golang(with Echo & Gorm & wire)

Posted at

お題

表題の通りだけど、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関数に書ききっても十分に収まる。

ソース全量

テーブル定義

[persistence/init/1_create.sql]
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パスワードなども直接記載。

[docker-compose.yml]
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ソース

[main.go]
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」というツール)

Screenshot from 2019-09-30 07-53-12.png

Postman」を使って、”商品登録”を3回実行。

Screenshot from 2019-09-30 08-03-22.png

Screenshot from 2019-09-30 08-03-49.png

Screenshot from 2019-09-30 08-04-08.png

”商品一覧”を実行すると、登録された3件分のJSONが表示される。

Screenshot from 2019-09-30 08-07-08.png

「商品」(item)テーブルにもレコードが登録されていることが確認できる。

Screenshot from 2019-09-30 08-04-46.png

クリーン・アーキテクチャ導入後

「冗談でしょ?」というくらいの構造になった。
こうなると、初見で「商品登録機能」の実装を確認しようとして、まずどのソースを見ればいいかわからないのでは?
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実装構造

関係

file.png

kazukousenさんのgoumlを使って自動生成したUMLクラス図のクラス種別だけを修正したもの
https://github.com/kazukousen/gouml
kazukousenさん、ありがとう。

シーケンス

interfaceの実装と呼び出しは図に出来たものの、それでもこれだけだとまだ理解は難しいと思う。
やっぱり、HTTPクライアントから「商品を登録」する機能を叩いたところから、どういう順番でどのファイルの何が呼ばれるのかが流れで図示されてないと厳しいか。。。
というわけで、シーケンス図も作ってみた。(by PlantUML

ca-sequence.png
※完全に厳密にパッケージ構成、ソースコードの内容と合致しているわけではないけど、おおよそ上記のイメージ

上記と↓の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.処理結果を、再び各層を経て、クライアントに返却する。

ソース全量

”商品”を登録する機能

先述のシーケンス図と見比べながらなら、ぎりぎり理解できるだろうか。。。
いや、自分でも最初は書きながら迷子になっていたから、やっぱりこれだけ層を分けて初見で理解って難しいんじゃ・・・。
(そもそもクリーンアーキテクチャとしてちゃんとした書き方になっているかも確信がないし)

[adapter/controller/item.go]
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,
    }
}
[usecase/item.go]
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,
    }
}
[domain/item.go]
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)
}
[domain/repository/item.go]
package repository

import (
    "go-ca-webapi/02_cleanarchitecture/domain/model"
)

type Item interface {
    SaveItem(m *model.Item) error
}
[adapter/gateway/item.go]
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,
    }
}
[usecase/outputport/item.go]
package outputport

import (
    usecasemodel "go-ca-webapi/02_cleanarchitecture/usecase/model"
)

type ItemOutputPort interface {
    RenderSaveResult(target *usecasemodel.SaveItem) error
    RenderFailure(err error) error
}
[adapter/presenter/item.go]
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

[main.go]
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"))
}
[wire.go]
//+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{}
}
[wire_gen.go]
// 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)
[driver/db.go]
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サービス作るくらいの勢いでクリーンアーキテクチャ取り入れてみないとダメかな。

20
19
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
20
19