LoginSignup
448
469

More than 3 years have passed since last update.

Goでヘキサゴナルアーキテクチャ

Last updated at Posted at 2019-11-17

はじめに

『Standard Go Project Layout』と『ヘキサゴナルアーキテクチャ』を参考にサンプルプロジェクトを作ってみました。

トランザクション周りも取り扱います。

『Standard Go Project Layout』とは

↓これです。
Standard Go Project Layout

上記の内容を日本語で簡潔にまとめてくださってる記事もありました。
Goにはディレクトリ構成のスタンダードがあるらしい。

別の記事になりますが、こちらもとても参考になりました。
Practical Go: Real world advice for writing maintainable Go programs

ヘキサゴナルアーキテクチャとは

↓これです。
ヘキサゴナルアーキテクチャ(Hexagonal architecture翻訳)

本家サイトへのリンクも張りたかったのですが、現在工事中とのことでした。。。

ヘキサゴナルアーキテクチャはレイヤードアーキテクチャ・オニオンアーキテクチャ・クリーンアーキテクチャの並びで語られることが多いですが、これらの中だと個人的にはヘキサゴナルアーキテクチャが簡潔かつ重要なポイントをわかりやすく解説しているなぁという印象です。

クリーンアーキテクチャはこれらの共通要素を抽出しているものなので、少し抽象度が上がり過ぎている感じがします。いきなりクリーンアーキテクチャに行くよりも、まずはヘキサゴナルアーキテクチャを勉強してみるとイメージが湧きやすいんじゃないかなと今回感じました。

ヘキサゴナルアーキテクチャにおける重要ポイント

上記の翻訳記事のなかで個人的に重要だと思う箇所を抜き出していきたいと思います。

アプリケーションを、ユーザー、プログラム、自動テストあるいはバッチスクリプトから、同じように駆動できるようにする。

プログラムを起動するのがコマンドライン・HTTPリクエスト・別のプログラム(ライブラリとして利用)・バッチスクリプトであるかに関わらず動くようにするということですね。


イベントが外側の世界からポートに届くと、特定テクノロジーのアダプターが、利用可能な手続き呼び出しか、メッセージにそれを変換して、アプリケーションに渡す。よろこばしいことに、アプリケーションは、入力デバイスの正体を知らない。

「外界 → アプリケーション」の向きの場合は、アダプターのことを「Controller」という名称でよく呼んでいる気がします。

theonionarchitecturepart3_67c4image05.png


アプリケーションがなにかを送る必要があるとき、それはポートを通じてアダプターに送られて、受信側のテクノロジーが必要とする信号を生む(人力であれ自動であれ)。

CleanArchitecture.jpg


アプリケーションは、データを取得するために外部のエンティティーと通信する。そのプロトコルの典型は、データベースプロトコルだ。アプリケーションの観点からは、もしデータベースがSQLデータベースから、フラットなファイルや、その他のデータベースに移行しても、APIとの会話は変わるべきではない。ゆえに、同じポートへの追加のアダプターは、SQLアダプター、フラットファイルアダプター、そしてもっとも重要なものとして、「モック」データベースのアダプターを含む。これは、メモリ内に居座るもので、実際のデータベースの存在にまったく依存しない。


多くのアプリケーションは、ポートを2つだけ持つ: ユーザー側の対話と、データベース側の対話だ。


「ポートとアダプター」という用語は、素描のパーツの「目的」を強調している。ポートは、目的の会話を識別する。典型的には、どのひとつのポートにも複数のアダプターがあるだろう。それらは、ポートに差し込まれるさまざまな技術のためのものだ。

ヘキサゴナルアーキテクチャで実装してみる

それでは実際にヘキサゴナルアーキテクチャでプログラムを書いてみるとどのようになるかを試していきます。今回特に注目するのは次の3点です

  • 「外界 → アプリケーション」の管理
  • 「アプリケーション → 外界」の管理
  • トランザクションの管理

また、今回の完成形のディレクトリ構成は次のようになりましたので先に掲載しておきます。

スクリーンショット 2019-11-17 13.29.49.png

ソースコードの全体はGitHubリポジトリにアップしていますので、確認したい方はぜひ。
https://github.com/rema424/hexample

今回はプロジェクト名を hexample として実装しています。

1. 「外界 → アプリケーション」

ヘキサゴナルアーキテクチャにおいては、アプリケーションのコアのロジックをコマンドライン・HTTPリクエスト・バッチプログラム・別のプログラム(ライブラリとして利用)などから同じように呼び出せるようにすることを目指します。

今回は「コマンドライン」「HTTPリクエスト」「ライブラリ」の3つの呼び出し方に対応するプログラムを作ってみます。

今回はコアロジックを internal/ ディレクトリに隠蔽するようにしてみます。

1.1. コアロジックを作る

internal/ の中に新規のパッケージを作り、コードを書いていきます。なお、本記事では internal/ ディレクトリに配置するパッケージの名前を service1service2... のように名付けていきます。Practical Go: Real world advice for writing maintainable Go programsの記事によると、Goのパッケージ名はそのパッケージが何を提供するかで決めるのが推奨とのことです。

Name your package for what it provides, not what it contains.

今回はシーケンス番号を付与してパッケージ名としていますが、本来であれば提供するサービスの内容をディレクトリ名にするのが良さそうです。

ドメイン駆動設計を実践する場合には internal/ ディレクトリの中に作成するパッケージは「境界づけられたコンテキスト」に基づくようにすると上手くいくような気がします。

ドメイン分析を使用したマイクロサービスのモデル化 | Microsoft Docs

実際のソースコードです。アプリケーションコアロジックとは言っても、まずは標準出力に文字を表示するだけのプログラムです。

スクリーンショット 2019-11-17 14.04.06.png

internal/service1/service1.go
package service1

import (
    "context"
    "fmt"
)

// AppCoreLogicIn .
type AppCoreLogicIn struct {
    From    string
    Message string
}

// AppCoreLogic .
func AppCoreLogic(ctx context.Context, in AppCoreLogicIn) {
    fmt.Println("--------------------------------------------------")
    fmt.Println("service1:")
    fmt.Println("this is application core logic.")
    fmt.Printf("from: %s, message: %s\n", in.From, in.Message)
    fmt.Println("--------------------------------------------------")
}

1.2. コマンドラインツールからコアロジックを呼び出す

まずはコマンドラインツールからコアロジックを呼び出してみます。今回は cobra を利用してコマンドラインツールを作成します。

プログラムを起動する際のエントリーポイント(main.go)は cmd/ ディレクトリ配下に設置することが一般的なようです。なお、cmd/ の中には今後HTTPリクエストを受け付けるためのプログラムのエントリーポイントも設置するので、cmd/cli/ というサブディレクトリを作成してこちらに main.go を配置します。

スクリーンショット 2019-11-17 14.04.45.png

cmd/cli/main.go
package main

import "github.com/rema424/hexample/cmd/cli/cmd"

func main() {
    cmd.Execute()
}
cmd/cli/cmd/root.go
package cmd

import (
    "context"
    "fmt"
    "os"

    "github.com/rema424/hexample/internal/service1"
    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Run: func(cmd *cobra.Command, args []string) {
        var msg string
        if len(args) != 0 {
            msg = args[0]
        } else {
            msg = "Hello, from cli!"
        }

        arg := service1.AppCoreLogicIn{
            From:    "cli",
            Message: msg,
        }

        service1.AppCoreLogic(context.Background(), arg)
    },
}

// Execute ...
func Execute() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

cmd/cli/cmd/root.go が今回アダプターの役割を担っています。アプリケーションコアは「パラメータがコマンドライン引数で渡ってくる」ということは知らないので、代わりにこのアダプターがコマンドライン引数を解釈して、実際のアプリケーションコアにパラメータとして渡しています。

なお、ディレクトリ階層に cmd/.../cmd/... と 2 回 cmd が現れるのが気になりますが、これが cobra のデフォルトなのでしょうがないですね・・・

また、cmd/ ディレクトリ配下に main.go 以外のプログラム(つまりアダプター)を配置して良いものかどうかも意見が別れる部分かなと思います。今回は cmd/ 配下にアダプターも配置するようにしました。例えば Go のテストコードジェネレータである cweill/gotests もこのディレクトリ構成です。

それではプログラムを起動してみます。

$ go run cmd/cli/main.go "おはようございます"
--------------------------------------------------
service1:
this is application core logic.
from: cli, message: おはようございます
--------------------------------------------------

スクリーンショット 2019-11-17 14.21.39.png

アプリケーションロジックを呼び出すことができました。

1.3. Webサーバーからコアロジックを呼び出す

次に、HTTP リクエストでアプリケーションのコアロジックを起動してみます。今回は echo を利用してWebサーバーを立ち上げます。

cmd/http/ ディレクトリにプログラムを追加していきます。

スクリーンショット 2019-11-17 14.28.10.png

cmd/http/main.go
package main

import (
    "fmt"
    "log"
    "net/http"
    "os"

    "github.com/rema424/hexample/cmd/http/controller"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

var e = createMux()

func main() {
    http.Handle("/", e)

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
        log.Printf("Defaulting to port %s", port)
    }

    log.Printf("Listening on port %s", port)
    log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil))
}

func init() {
    ctrl := &controller.Controller{}

    e.GET("/:message", ctrl.HandleMessage)
}

func createMux() *echo.Echo {
    e := echo.New()

    e.Use(middleware.Recover())
    e.Use(middleware.Logger())
    e.Use(middleware.Gzip())

    return e
}
cmd/http/controller/controller.go
package controller

import (
    "github.com/rema424/hexample/internal/service1"

    "github.com/labstack/echo/v4"
)

// Controller ...
type Controller struct{}

// HandleMessage ...
func (ctrl *Controller) HandleMessage(c echo.Context) error {
    msg := c.Param("message")
    if msg == "" {
        msg = "Hello, from http!"
    }

    arg := service1.AppCoreLogicIn{
        From:    "http",
        Message: msg,
    }

    service1.AppCoreLogic(c.Request().Context(), arg)
    return nil
}

それではWebサーバーを起動して curl でリクエストを実行してみます。パラメータはパスパラメータで渡します。

# Webサーバー起動
go run cmd/http/main.go

# 別の shell を立ち上げてリクエストを実行
curl localhost:8080/こんにちは

# Webサーバーを起動した方の shell に以下の出力
--------------------------------------------------
service1:
this is application core logic.
from: http, message: こんにちは
--------------------------------------------------

スクリーンショット 2019-11-17 14.36.57.png

コアロジックを呼び出すことができました。

コアロジックではパラメータがパスパラメータで渡ってくるということを知りませんが、アダプターがこの解釈の部分を担っています。このように、アプリケーションとユーザーの間にアダプターを挟むことで、どのような形で処理がリクエストされる場合でも、コアロジック側の実装を変えることなく新しい呼び出し元に対応することができます。

1.4. 別プログラムからコアロジックを呼び出す

要するにライブラリとしての利用です。

ライブラリとして internal/ 配下のロジックを使うには、プロジェクトのトップ階層にプロジェクト名と同名のファイルを作成し、これをアダプターとして利用するパターンが多いようです。

スクリーンショット 2019-11-17 14.42.53.png

hexample.go
package hexample

import (
    "context"

    "github.com/rema424/hexample/internal/service1"
)

// Run ...
func Run(ctx context.Context, msg string) {
    if msg == "" {
        msg = "Hello, from external pkg!"
    }
    arg := service1.AppCoreLogicIn{
        From:    "external pkg",
        Message: msg,
    }
    service1.AppCoreLogic(ctx, arg)
}

go.mod の module 名がこのパッケージ名に合致しているか確認します。

スクリーンショット 2019-11-17 14.46.13.png

あとはこのソースコードを GitHub か何かにアップして公開し、別のプロジェクトでインポートして利用します。

package main

import (
    "context"

    "github.com/rema424/hexample"
)

func main() {
    ctx := context.Background()
    msg := "こんばんは"
    hexample.Run(ctx, msg)
}

このプログラムを実行すると次のようになります。

go run main.go
--------------------------------------------------
service1:
this is application core logic.
from: external, message: こんばんは
--------------------------------------------------

スクリーンショット 2019-11-17 14.52.24.png

コアロジックを呼び出すことができました。

今回はコマンドラインツール・HTTPリクエスト・ライブラリからのアプリケーションの利用を見てみましたが、同様にアダプターを追加していけば他のプロトコルにも対応させることができます。このような場合でもアプリケーションのコアのソースコードを書き換える必要はありません。アダプターがこれらに対応します。

2. 「アプリケーション → 外界」

次に「アプリケーション → 外界」の向き、つまり「アプリケーション → データベース」について見ていきます。ユーザーからの入力をアプリケーションが知らなくてよかったように、データベースについてもアプリケーションは知らずに済むように作っていきます。

これを実現するためには「依存関係逆転の原則(DIP, dependency inversion principle)」または「依存性注入(dependency injection)」と呼ばれる技法を使います。ここら辺はソースコードを見た方が早いですね。

今回の例では次のようなプログラムを作ります。

  • Person の登録
  • Person の1件取得
  • ユーザー側は HTTP リクエストに対応
  • 本番 DB として MySQL を利用
  • モック DB としてメモリ(Goのmap)を利用

リレーショナルデータベースのスキーマは次の通りです。

create table if not exists person (
  id bigint auto_increment,
  name varchar(255),
  email varchar(255),
  primary key (id)
);

2.1. アプリケーションコアの実装

まずはアプリケーションのコアを作成していきます。アダプター(Gateway)にはまだ手を付けません。新しいパッケージを作成して開発します。

スクリーンショット 2019-11-17 15.10.33.png

internal/service2/model.go
package service2

// Person ...
type Person struct {
    ID    int64  `db:"kokoha"`
    Name  string `db:"tekitode"` // sql.NullString はインフラに結合するので使わない
    Email string `db:"yoiyo"`
}

モデルにはオブジェクトを定義していきます。オブジェクト特有の振る舞い(メソッド)もここに記述しますが、今回のプログラムは簡素なためメソッドはありません。重要なのはインフラの知識を持ち込まないことです。dbタグがありますが、ここは適当でいいです。モデルがDBの事情に合わせるのではなく、アダプター(Gateway)がこのdbタグと実際のカラム名の調整を行います。

internal/service2/repository.go
package service2

import "context"

// Repository ...
type Repository interface {
    RegisterPerson(context.Context, Person) (Person, error)
    GetPersonByID(context.Context, int64) (Person, error)
}

リポジトリは今回インタフェースとして利用するのでこれくらいです。アプリケーションコアなのでここにもインフラに関する知識は出てきません。

internal/service2/provider.go
package service2

import "context"

// Provider ...
type Provider struct {
    r Repository
}

// NewProvider ...
func NewProvider(r Repository) *Provider {
    return &Provider{r}
}

// RegisterPerson ...
func (p *Provider) RegisterPerson(ctx context.Context, name, email string) (Person, error) {
    psn := Person{
        Name:  name,
        Email: email,
    }

    psn, err := p.r.RegisterPerson(ctx, psn)
    if err != nil {
        return Person{}, err
    }

    return psn, nil
}

// GetPersonByID ...
func (p *Provider) GetPersonByID(ctx context.Context, id int64) (Person, error) {
    psn, err := p.r.GetPersonByID(ctx, id)
    if err != nil {
        return Person{}, err
    }

    return psn, nil
}

プロバイダーは提供するサービス内容を記述していきます。クリーンアーキテクチャでは Use Case ですね。 今回は 『Practical Go: Real world advice for writing maintainable Go programs』の中から言葉をもらって Provider にしてみました。

なお、コントローラーに対してモデルの型を公開するかどうか(引数や戻り値にモデルの型を使う)については議論があるかと思います。今回はアダプターに対してモデルの型を公開してもよいという方針でプログラムを作っています。(DB側のアダプターであるGatewayにはモデルの型を公開することになりますし。)

2.2. MySQL用アダプターの実装

Repository のインタフェースを満たすように、MySQL と疎通するための Gateway を実装します。なお、今回は sqlx を O/R マッパーとして利用します。

スクリーンショット 2019-11-17 15.33.37.png

internal/service2/gateway.go
package service2

import (
    "context"

    "github.com/jmoiron/sqlx"
)

// Gateway ...
type Gateway struct {
    db *sqlx.DB
}

// NewGateway ...
func NewGateway(db *sqlx.DB) Repository {
    return &Gateway{db}
}

// RegisterPerson ...
func (r *Gateway) RegisterPerson(ctx context.Context, p Person) (Person, error) {
    q := `INSERT INTO person (name, email) VALUES (:tekitode, :yoiyo);`
    res, err := r.db.NamedExecContext(ctx, q, p)
    if err != nil {
        return Person{}, err
    }

    id, err := res.LastInsertId()
    if err != nil {
        return Person{}, err
    }

    p.ID = id
    return p, nil
}

// GetPersonByID ...
func (r *Gateway) GetPersonByID(ctx context.Context, id int64) (Person, error) {
    // DB上のnull対策はここで実装する
    q := `
SELECT
  COALESCE(id, 0) AS 'kokoha',
  COALESCE(name, '') AS 'tekitode',
  COALESCE(email, '') AS 'yoiyo'
FROM person
WHERE id = ?;
`
    var p Person
    err := r.db.GetContext(ctx, &p, q, id)
    return p, err
}

Gateway はインフラにどっぷり浸かります。DB の null への対策や、モデルのフィールド名とレコードのカラム名の繋ぎ混みなどを担います。

なお NewGateway() 関数は、戻り値の型は Repository インタフェースとなっていますが、実際に返却しているのは Gateway 構造体(のポインタ)になっています。

2.3. ユーザー側アダプター(controller)の実装とルーティングの追加

現在の話の本筋ではありませんが、プログラムの実行のために実装します。

スクリーンショット 2019-11-17 15.44.46.png

cmd/http/controller/controller2.go
package controller

import (
    "net/http"
    "strconv"

    "github.com/rema424/hexample/internal/service2"

    "github.com/labstack/echo/v4"
)

// Controller2 ...
type Controller2 struct {
    p *service2.Provider
}

// NewController2 ...
func NewController2(p *service2.Provider) *Controller2 {
    return &Controller2{p}
}

// HandlePersonRegister ...
// curl -X POST -H 'Content-type: application/json' -d '{"name": "Alice", "email": "alice@example.com"}' localhost:8080/people
func (ctrl *Controller2) HandlePersonRegister(c echo.Context) error {
    in := struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }{}

    if err := c.Bind(&in); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }

    // TODO: implement
    // if err := c.Validate(&in); err != nil {
    //  return c.JSON(http.StatusUnprocessableEntity, err.Error())
    // }

    ctx := c.Request().Context()
    psn, err := ctrl.p.RegisterPerson(ctx, in.Name, in.Email)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, err.Error())
    }

    return c.JSON(http.StatusOK, psn)
}

// HandlePersonGet ...
// curl localhost:8080/people/999
func (ctrl *Controller2) HandlePersonGet(c echo.Context) error {
    id, err := strconv.Atoi(c.Param("personID"))
    if err != nil {
        return c.JSON(http.StatusUnprocessableEntity, err.Error())
    }

    ctx := c.Request().Context()
    psn, err := ctrl.p.GetPersonByID(ctx, int64(id))
    if err != nil {
        return c.JSON(http.StatusInternalServerError, err.Error())
    }

    return c.JSON(http.StatusOK, psn)
}

サーバー側で発生したエラーをそのままクライアントに返却してしまっていますが、サンプルということでお見逃しください:pray_tone2:

cmd/http/main.go
package main

import (
    "fmt"
    "log"
    "net/http"
    "os"

    "github.com/rema424/hexample/cmd/http/controller"
    "github.com/rema424/hexample/internal/service2"
    "github.com/rema424/hexample/pkg/mysql"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

var e = createMux()

func main() {
    http.Handle("/", e)

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
        log.Printf("Defaulting to port %s", port)
    }

    log.Printf("Listening on port %s", port)
    log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil))
}

func init() {
    // Mysql
    c := mysql.Config{
        Host:                 os.Getenv("DB_HOST"),
        Port:                 os.Getenv("DB_PORT"),
        User:                 os.Getenv("DB_USER"),
        DBName:               os.Getenv("DB_NAME"),
        Passwd:               os.Getenv("DB_PASSWORD"),
        AllowNativePasswords: true,
    }

    db, err := mysql.Connect(c)
    if err != nil {
        log.Fatalln(err)
    }

    ctrl := &controller.Controller{}

    // DI
    gateway2 := service2.NewGateway(db)
    provider2 := service2.NewProvider(gateway2)
    ctrl2 := controller.NewController2(provider2)

    e.GET("/:message", ctrl.HandleMessage)
    e.GET("/people/:personID", ctrl2.HandlePersonGet)
    e.POST("/people", ctrl2.HandlePersonRegister)
}

func createMux() *echo.Echo {
    e := echo.New()

    e.Use(middleware.Recover())
    e.Use(middleware.Logger())
    e.Use(middleware.Gzip())

    return e
}

init() 関数で DI とルーティングの追加を行なっています。

また、データベースとの接続には pkg/mysql/ というパッケージを利用しています。今回しれっと追加したソースコードです。

スクリーンショット 2019-11-17 15.57.26.png

pkg/mysql/mysql.go
package mysql

import (
    "time"

    my "github.com/go-sql-driver/mysql"
    "github.com/jmoiron/sqlx"
)

// Config ...
type Config struct {
    User                 string
    Passwd               string
    Host                 string
    Port                 string
    Net                  string
    Addr                 string
    DBName               string
    Collation            string
    InterpolateParams    bool
    AllowNativePasswords bool
    ParseTime            bool
    MaxOpenConns         int
    MaxIdleConns         int
    ConnMaxLifetime      time.Duration
}

func (c Config) build() Config {
    if c.User == "" {
        c.User = "root"
    }
    if c.Net == "" {
        c.Net = "tcp"
    }
    if c.Host == "" {
        c.Host = "127.0.0.1"
    }
    if c.Port == "" {
        c.Port = "3306"
    }
    if c.Addr == "" {
        c.Addr = c.Host + ":" + c.Port
    }
    if c.Collation == "" {
        c.Collation = "utf8mb4_bin"
    }
    if c.MaxOpenConns < 0 {
        c.MaxOpenConns = 30
    }
    if c.MaxIdleConns < 0 {
        c.MaxIdleConns = 30
    }
    if c.ConnMaxLifetime < 0 {
        c.ConnMaxLifetime = 60 * time.Second
    }
    return c
}

// Connect .
func Connect(c Config) (*sqlx.DB, error) {
    c = c.build()

    mycfg := my.Config{
        User:                 c.User,
        Passwd:               c.Passwd,
        Net:                  c.Net,
        Addr:                 c.Addr,
        DBName:               c.DBName,
        Collation:            c.Collation,
        InterpolateParams:    c.InterpolateParams,
        AllowNativePasswords: c.AllowNativePasswords,
        ParseTime:            c.ParseTime,
    }

    dbx, err := sqlx.Connect("mysql", mycfg.FormatDSN())
    if err != nil {
        return nil, err
    }

    dbx.SetMaxOpenConns(c.MaxOpenConns)
    dbx.SetMaxIdleConns(c.MaxIdleConns)
    dbx.SetConnMaxLifetime(c.ConnMaxLifetime)

    return dbx, nil
}

Go では アプリケーションのコアではないソースコードは pkg というディレクトリの中に作成するのが慣習となっているようです。他の言語やフレームワークだと libutil などと名付けられているかと思います。ここに配置されるソースコードは別のプロジェクトからでも利用が可能なソースコードです。現在のアプリケーションでのみ有効的に利用できる処理であれば internal/ 配下に設置した方がいいかもしれません。この例で有名なのが Docker の namesgenerator (現: Moby Project)でしょうか。

それではデータベースや環境変数をよしなに準備した上で、Webサーバーを起動し curl でアプリケーションの処理を呼び出してみます。

$ go run cmd/http/main.go
2019/11/17 16:11:02 Defaulting to port 8080
2019/11/17 16:11:02 Listening on port 8080
$ curl -X POST -H 'Content-type: application/json' -d '{"name": "Alice", "email": "alice@example.com"}' localhost:8080/people
{"ID":1,"Name":"Alice","Email":"alice@example.com"}

$ curl -X POST -H 'Content-type: application/json' -d '{"name": "Bob", "email": "bob@example.com"}' localhost:8080/people
{"ID":2,"Name":"Bob","Email":"bob@example.com"}

$ curl localhost:8080/people/1
{"ID":1,"Name":"Alice","Email":"alice@example.com"}

$ curl localhost:8080/people/2
{"ID":2,"Name":"Bob","Email":"bob@example.com"}

スクリーンショット 2019-11-17 16.17.16.png

スクリーンショット 2019-11-17 16.15.47.png

Gatewayを通じてアプリケーションがデータベースと疎通できました。

2.4. MockDB用アダプターの実装

それでは次にメモリを利用した MockDB のアダプターを実装していきます。並行アクセスに対処するため、相互排他(Mutual eXclusion)を用いて制御します。また、RDBにおける自動採番の代わりに擬似乱数を用いてIDを発行します。

スクリーンショット 2019-11-17 18.57.23.png

追記2(2019/11/20)

今回作ったの MockDB + MockGateway じゃなくて MockDB + FakeGateway だったかもしれません。

Test Doubles — Fakes, Mocks and Stubs.

internal/service2/mock_gateway.go
package service2

import (
    "context"
    "fmt"
    "math/rand"
    "sync"
    "time"
)

var src = rand.NewSource(time.Now().UnixNano())

// MockGateway ...
type MockGateway struct {
    db *MockDB
}

// MockDB ...
type MockDB struct {
    mu   sync.RWMutex
    data map[int64]Person
}

// NewMockDB ...
func NewMockDB() *MockDB {
    return &MockDB{data: make(map[int64]Person)}
}

// NewMockGateway ...
func NewMockGateway(db *MockDB) Repository {
    return &MockGateway{db}
}

// RegisterPerson ...
func (r *MockGateway) RegisterPerson(ctx context.Context, p Person) (Person, error) {
    r.db.mu.Lock()
    defer r.db.mu.Unlock()

    // 割り当て可能なIDを探す
    var id int64
    for {
        id = src.Int63()
        _, ok := r.db.data[id]
        if !ok {
            break
        }
    }

    p.ID = id
    r.db.data[p.ID] = p

    return p, nil
}

// GetPersonByID ...
func (r *MockGateway) GetPersonByID(ctx context.Context, id int64) (Person, error) {
    r.db.mu.RLock()
    defer r.db.mu.RUnlock()

    if p, ok := r.db.data[id]; ok {
        return p, nil
    }
    return Person{}, fmt.Errorf("person not found - id: %d", id)
}

続いて main.go において DI の部分を修正し、Provider が Reository として Gateway ではなく MockGateway を利用するようにします。

cmd/http/main.go
func init() {
    // Mysql
    // c := mysql.Config{
    //  Host:                 os.Getenv("DB_HOST"),
    //  Port:                 os.Getenv("DB_PORT"),
    //  User:                 os.Getenv("DB_USER"),
    //  DBName:               os.Getenv("DB_NAME"),
    //  Passwd:               os.Getenv("DB_PASSWORD"),
    //  AllowNativePasswords: true,
    // }

    // db, err := mysql.Connect(c)
    // if err != nil {
    //  log.Fatalln(err)
    // }

    // DI
    // gateway2 := service2.NewGateway(db)
    // provider2 := service2.NewProvider(gateway2)
    mockGateway2 := service2.NewMockGateway(service2.NewMockDB())
    provider2 := service2.NewProvider(mockGateway2)

    ctrl := &controller.Controller{}
    ctrl2 := controller.NewController2(provider2)

    e.GET("/:message", ctrl.HandleMessage)
    e.GET("/people/:personID", ctrl2.HandlePersonGet)
    e.POST("/people", ctrl2.HandlePersonRegister)
}

Webサーバーを起動して curl でプログラムの処理を呼んでみます。なお、ご自身で試される際にはIDが擬似乱数になっている点にご注意ください。

$ go run cmd/http/main.go
$ curl -X POST -H 'Content-type: application/json' -d '{"name": "Alice", "email": "alice@example.com"}' localhost:8080/people
{"ID":4604021376263565598,"Name":"Alice","Email":"alice@example.com"}

$ curl -X POST -H 'Content-type: application/json' -d '{"name": "Bob", "email": "bob@example.com"}' localhost:8080/people
{"ID":6891153250004441175,"Name":"Bob","Email":"bob@example.com"}

$ curl localhost:8080/people/4604021376263565598
{"ID":4604021376263565598,"Name":"Alice","Email":"alice@example.com"}

$ curl localhost:8080/people/6891153250004441175
{"ID":6891153250004441175,"Name":"Bob","Email":"bob@example.com"}

スクリーンショット 2019-11-17 19.11.56.png

MockDB を利用してもアプリケーションのコアロジックを実行することができました。

前のセクションと合わせて「外界 → アプリケーション」「アプリケーション → 外界」の両方向において、アプリケーションのコアロジックをいじることなく外界とコミュニケーションがとれるようになりました。

次のセクションでは「トランザクション管理」について見ていきます。

3. トランザクション管理

それではトランザクション管理方法の手法について考えていきます。

今回はサンプルプログラムとして「銀行口座における送金プログラム」を作ってみます。リレーショナルデータベースにおけるスキーマは次の通りです。

create table if not exists account (
  id bigint auto_increment,
  balance int,
  primary key (id)
);

3.1. トランザクションを管理するのはどのレイヤーか?

プログラムの作成に先立って「トランザクションを管理するのはどのレイヤーか」ということについて考えてみます。本記事に沿って考えると、トランザクションの開始を宣言するのは Provider なのか Gateway なのかということです。

『エリック・エヴァンスのドメイン駆動設計』ではアプリケーション層(本記事における Provider )がトランザクションの開始を宣言するように記載されています。「第4章 ドメインを隔離する」の図4.1のシーケンス図で示されているので、書籍をお持ちの方は確認してみてください。本記事でもこれに習って Provider がトランザクションの開始を宣言できるように実装します。(なお、後述しますが、「トランザクションの開始は必ずアプリケーション層が通知しなければならない」とするのは問題があるように思います。場合によっては Gateway がトランザクションを開始したほうが良いケースもあるのでないかと感じています。)

3.2. アプリケーションコアの実装

アプリケーションコアはインフラの知識が入り込まないように注意します。 Repository において RunInTransaction() というメソッドを定義していますが、引数・戻り値にもインフラに関する知識は入り込んでいません。

スクリーンショット 2019-11-17 19.52.34.png

internal/service3/model.go
package service3

// Account ...
type Account struct {
    ID      int64 `db:"aikawarazu"`
    Balance int   `db:"tekitode"`
}

// IsSufficient ...
func (a *Account) IsSufficient(ammount int) bool {
    return a.Balance >= ammount
}

// Transfer ...
func (a *Account) Transfer(ammount int, to *Account) {
    a.Balance -= ammount
    to.Balance += ammount
}
internal/service3/provider.go
package service3

import "context"

// Repository ...
type Repository interface {
    RunInTransaction(context.Context, func(context.Context) (interface{}, error)) (interface{}, error)
    OpenAccount(ctx context.Context, initialAmmount int) (Account, error)
    GetAccountsForTransfer(ctx context.Context, fromID, toID int64) (from, to Account, err error)
    UpdateBalance(ctx context.Context, a Account) (Account, error)
}
internal/service3/provider.go
package service3

import (
    "context"
    "fmt"
)

// Provider ...
type Provider struct {
    r Repository
}

// NewProvider ...
func NewProvider(r Repository) *Provider {
    return &Provider{r}
}

// OpenAccount ...
func (p *Provider) OpenAccount(ctx context.Context, initialAmmount int) (Account, error) {
    if initialAmmount <= 0 {
        return Account{}, fmt.Errorf("provider: initial ammount must be greater than 0")
    }

    account, err := p.r.OpenAccount(ctx, initialAmmount)
    if err != nil {
        return Account{}, err
    }
    return account, nil
}

// Transfer ...
func (p *Provider) Transfer(ctx context.Context, ammount int, fromID, toID int64) (from, to Account, err error) {
    if fromID == toID {
        return Account{}, Account{}, fmt.Errorf("provider: cannot transfer money to oneself")
    }

    type Accounts struct {
        from Account
        to   Account
    }

    // トランザクションで実行したい処理をまとめる
    txFn := func(ctx context.Context) (interface{}, error) {
        // 送金元、送金先の口座を取得する
        from, to, err := p.r.GetAccountsForTransfer(ctx, fromID, toID)
        if err != nil {
            return Accounts{}, err
        }

        // 送金元の残高を確認
        if !from.IsSufficient(ammount) {
            return Accounts{}, fmt.Errorf("provider: balance is not sufficient - accountID: %d", from.ID)
        }

        // 送金する
        from.Transfer(ammount, &to)

        // 送金元の残高を更新する
        from, err = p.r.UpdateBalance(ctx, from)
        if err != nil {
            return Accounts{}, err
        }

        // 送金先の残高を更新する
        to, err = p.r.UpdateBalance(ctx, to)
        if err != nil {
            return Accounts{}, err
        }

        return Accounts{from: from, to: to}, nil
    }

    // トランザクションでまとめて処理を実行
    v, err := p.r.RunInTransaction(ctx, txFn)
    if err != nil {
        return Account{}, Account{}, err
    }

    val, ok := v.(Accounts)
    if !ok {
        return Account{}, Account{}, fmt.Errorf("provider: an error occurs - transfer")
    }

    return val.from, val.to, nil
}

3.3. MockDB用アダプターの実装

今回は先に MockDB の方の Gateway を実装します。

インメモリの MockDB におけるトランザクションは context と mutual exclusion を利用して実装していきます。なお、今回は RDB のようなロールバックは実装せず、Redis のように間に他の処理が入らないようにするトランザクションを実装します。

internal/service3/mock_gateway.go
package service3

import (
    "context"
    "fmt"
    "math/rand"
    "sync"
    "time"
)

type ctxKey string

const txCtxKey ctxKey = "transaction"

var src = rand.NewSource(time.Now().UnixNano())

// MockGateway ...
type MockGateway struct {
    db *MockDB
}

// NewMockGateway ...
func NewMockGateway(db *MockDB) Repository {
    return &MockGateway{db}
}

// MockDB ...
type MockDB struct {
    mu   sync.RWMutex
    data map[int64]Account
}

// NewMockDB ...
func NewMockDB() *MockDB {
    return &MockDB{data: make(map[int64]Account)}
}

// OpenAccount ...
func (g *MockGateway) OpenAccount(ctx context.Context, initialAmmount int) (Account, error) {
    if !isInTx(ctx) {
        g.db.mu.Lock()
        defer g.db.mu.Unlock()
    }

    // 割り当て可能なIDを探す
    var id int64
    for {
        id = src.Int63()
        _, ok := g.db.data[id]
        if !ok {
            break
        }
    }

    a := Account{ID: id, Balance: initialAmmount}
    g.db.data[id] = a
    return a, nil
}

// GetAccountsForTransfer ...
func (g *MockGateway) GetAccountsForTransfer(ctx context.Context, fromID, toID int64) (from, to Account, err error) {
    if !isInTx(ctx) {
        g.db.mu.Lock()
        defer g.db.mu.Unlock()
    }

    var ok bool
    from, ok = g.db.data[fromID]
    if !ok {
        return Account{}, Account{}, fmt.Errorf("gateway: account not found - accoutID: %d", fromID)
    }

    to, ok = g.db.data[toID]
    if !ok {
        return Account{}, Account{}, fmt.Errorf("gateway: account not found - accoutID: %d", toID)
    }

    return from, to, nil
}

// UpdateBalance ...
func (g *MockGateway) UpdateBalance(ctx context.Context, a Account) (Account, error) {
    if !isInTx(ctx) {
        g.db.mu.Lock()
        defer g.db.mu.Unlock()
    }

    g.db.data[a.ID] = a
    return a, nil
}

// RunInTransaction ...
func (g *MockGateway) RunInTransaction(ctx context.Context, txFn func(context.Context) (interface{}, error)) (interface{}, error) {
    // 多重トランザクションはエラーとする
    if isInTx(ctx) {
        return nil, fmt.Errorf("gateway: detect nested transaction")
    }

    // context をデコレートして transaction context を生成する
    txCtx := genTxCtx(ctx)

    // ロックを取得する(ロックの取得から解放までの間がトランザクションとなる)
    g.db.mu.Lock()
    defer g.db.mu.Unlock()

    // transaction 処理を実行する
    return txFn(txCtx)
}

func isInTx(ctx context.Context) bool {
    if val, ok := ctx.Value(txCtxKey).(bool); ok {
        return val
    }
    return false
}

func genTxCtx(ctx context.Context) context.Context {
    if isInTx(ctx) {
        return ctx
    }
    return context.WithValue(ctx, txCtxKey, true)
}

3.4. ユーザー側アダプター(controller)の実装とルーティングの追加

スクリーンショット 2019-11-17 20.07.56.png

cmd/http/controller/controller3.go
package controller

import (
    "net/http"

    "github.com/labstack/echo/v4"
    "github.com/rema424/hexample/internal/service3"
)

// Controller3 ...
type Controller3 struct {
    p *service3.Provider
}

// NewController3 ...
func NewController3(p *service3.Provider) *Controller3 {
    return &Controller3{p}
}

// HandleAccountOpen ...
// curl -X POST -H 'Content-type: application/json' -d '{"ammount": 1000}' localhost:8080/accounts
func (ctrl *Controller3) HandleAccountOpen(c echo.Context) error {
    in := struct {
        Ammount int `json:"ammount"`
    }{}

    if err := c.Bind(&in); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }

    // TODO: implement
    // if err := c.Validate(&in); err != nil {
    //  return c.JSON(http.StatusUnprocessableEntity, err.Error())
    // }

    ctx := c.Request().Context()
    psn, err := ctrl.p.OpenAccount(ctx, in.Ammount)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, err.Error())
    }

    return c.JSON(http.StatusOK, psn)
}

// HandleMoneyTransfer ...
// curl -X POST -H 'Content-type: application/json' -d '{"fromId": , "toId": , "ammount": 1000}' localhost:8080/accounts/transfer
func (ctrl *Controller3) HandleMoneyTransfer(c echo.Context) error {
    in := struct {
        FromAccountID int64 `json:"fromId"`
        ToAccountID   int64 `json:"toId"`
        Ammount       int   `json:"ammount"`
    }{}

    if err := c.Bind(&in); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }

    // TODO: implement
    // if err := c.Validate(&in); err != nil {
    //  return c.JSON(http.StatusUnprocessableEntity, err.Error())
    // }

    ctx := c.Request().Context()
    from, to, err := ctrl.p.Transfer(ctx, in.Ammount, in.FromAccountID, in.ToAccountID)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, err.Error())
    }

    return c.JSON(http.StatusOK, map[string]interface{}{"from": from, "to": to})
}
cmd/http/main.go
func init() {
    // Mysql
    // c := mysql.Config{
    //  Host:                 os.Getenv("DB_HOST"),
    //  Port:                 os.Getenv("DB_PORT"),
    //  User:                 os.Getenv("DB_USER"),
    //  DBName:               os.Getenv("DB_NAME"),
    //  Passwd:               os.Getenv("DB_PASSWORD"),
    //  AllowNativePasswords: true,
    // }
    // db, err := mysql.Connect(c)
    // if err != nil {
    //  log.Fatalln(err)
    // }
    // acsr, err := sqlxx.Open(db)
    // if err != nil {
    //  log.Fatalln(err)
    // }

    // Service2
    // gateway2 := service2.NewGateway(db)
    // provider2 := service2.NewProvider(gateway2)
    mockGateway2 := service2.NewMockGateway(service2.NewMockDB())
    provider2 := service2.NewProvider(mockGateway2)

    // Service3
    // gateway3 := service3.NewGateway(acsr)
    // provider3 := service3.NewProvider(gateway3)
    mockGateway3 := service3.NewMockGateway(service3.NewMockDB())
    provider3 := service3.NewProvider(mockGateway3)

    ctrl := &controller.Controller{}
    ctrl2 := controller.NewController2(provider2)
    ctrl3 := controller.NewController3(provider3)

    e.GET("/:message", ctrl.HandleMessage)
    e.GET("/people/:personID", ctrl2.HandlePersonGet)
    e.POST("/people", ctrl2.HandlePersonRegister)
    e.POST("/accounts", ctrl3.HandleAccountOpen)
    e.POST("/accounts/transfer", ctrl3.HandleMoneyTransfer)
}

Webサーバーを起動して curl でリクエストを実行してみます。

$ go run cmd/http/main.go
$ curl -X POST -H 'Content-type: application/json' -d '{"ammount": 1000}' localhost:8080/accounts
{"ID":6604275530202776837,"Balance":1000}

$ curl -X POST -H 'Content-type: application/json' -d '{"ammount": 1000}' localhost:8080/accounts
{"ID":8605590474089424096,"Balance":1000}

$ curl -X POST -H 'Content-type: application/json' -d '{"fromId": 6604275530202776837, "toId": 8605590474089424096, "ammount": 300}' localhost:8080/accounts/transfer
{"from":{"ID":6604275530202776837,"Balance":700},"to":{"ID":8605590474089424096,"Balance":1300}}

$ curl -X POST -H 'Content-type: application/json' -d '{"fromId": 6604275530202776837, "toId": 8605590474089424096, "ammount": 300}' localhost:8080/accounts/transfer
{"from":{"ID":6604275530202776837,"Balance":400},"to":{"ID":8605590474089424096,"Balance":1600}}

$ curl -X POST -H 'Content-type: application/json' -d '{"fromId": 6604275530202776837, "toId": 8605590474089424096, "ammount": 300}' localhost:8080/accounts/transfer
{"from":{"ID":6604275530202776837,"Balance":100},"to":{"ID":8605590474089424096,"Balance":1900}}

$ curl -X POST -H 'Content-type: application/json' -d '{"fromId": 6604275530202776837, "toId": 8605590474089424096, "ammount": 300}' localhost:8080/accounts/transfer
"provider: balance is not sufficient - accountID: 6604275530202776837"

$ curl -X POST -H 'Content-type: application/json' -d '{"fromId": 6604275530202776837, "toId": 8605590474089424096, "ammount": 100}' localhost:8080/accounts/transfer
{"from":{"ID":6604275530202776837,"Balance":0},"to":{"ID":8605590474089424096,"Balance":2000}}

スクリーンショット 2019-11-17 20.23.08.png

3.5. MySQL用アダプターの実装

次に MySQL におけるトランザクション管理の実装をしていきます。なんですが、、アプリケーションコアにDBの知識を漏らさずにトランザクションを開始するための実装が少し面倒だったので、トランザクション管理部分だけライブラリ化してしまいました。sqlx のラッパーになっています。ソースコードを確認したい方はリポジトリの方を覗いてみてください。テストなんてしてな(ry

internal/service3/gateway.go
package service3

import (
    "context"
    "fmt"
    "log"

    "github.com/rema424/sqlxx"
)

// Gateway ...
type Gateway struct {
    db *sqlxx.Accessor
}

// NewGateway ...
func NewGateway(db *sqlxx.Accessor) Repository {
    return &Gateway{db}
}

// OpenAccount ...
func (g *Gateway) OpenAccount(ctx context.Context, initialAmmount int) (Account, error) {
    q := `INSERT INTO account (balance) VALUES (?);`

    res, err := g.db.Exec(ctx, q, initialAmmount)
    if err != nil {
        return Account{}, err
    }

    id, err := res.LastInsertId()
    if err != nil {
        return Account{}, nil
    }

    return Account{ID: id, Balance: initialAmmount}, nil
}

// GetAccountsForTransfer ...
func (g *Gateway) GetAccountsForTransfer(ctx context.Context, fromID, toID int64) (from, to Account, err error) {
    // 送金に関わるアカウントはロックをかけて(FOR UPDATE)取得する
    q := `
SELECT
  COALESCE(id, 0) AS 'aikawarazu',
  COALESCE(balance, 0) AS 'tekitode'
FROM account
WHERE id = ? OR id = ?
FOR UPDATE;
`
    var dest []Account
    if err := g.db.Select(ctx, &dest, q, fromID, toID); err != nil {
        return from, to, err
    }

    if len(dest) != 2 {
        return from, to, fmt.Errorf("gateway: account not found for transfer")
    }

    for _, a := range dest {
        if a.ID == fromID {
            from = a
        } else if a.ID == toID {
            to = a
        }
    }

    return from, to, nil
}

// UpdateBalance ...
func (g *Gateway) UpdateBalance(ctx context.Context, a Account) (Account, error) {
    q := `UPDATE account SET balance = :tekitode WHERE id = :aikawarazu;`
    _, err := g.db.NamedExec(ctx, q, a)
    if err != nil {
        return Account{}, err
    }
    return a, nil
}

// RunInTransaction ...
func (g *Gateway) RunInTransaction(ctx context.Context, txFn func(context.Context) (interface{}, error)) (interface{}, error) {
    v, err, rlbkErr := g.db.RunInTx(ctx, txFn)
    if rlbkErr != nil {
        log.Printf("gateway: failed to rollback - err: %s\n", rlbkErr.Error())
    }
    return v, err
}

main.go の DI 部分、トランザクション管理用のライブラリ利用部分を書き換えます。

cmd/http/main.go
func init() {
    // Mysql
    c := mysql.Config{
        Host:                 os.Getenv("DB_HOST"),
        Port:                 os.Getenv("DB_PORT"),
        User:                 os.Getenv("DB_USER"),
        DBName:               os.Getenv("DB_NAME"),
        Passwd:               os.Getenv("DB_PASSWORD"),
        AllowNativePasswords: true,
    }
    db, err := mysql.Connect(c)
    if err != nil {
        log.Fatalln(err)
    }
    acsr, err := sqlxx.Open(db)
    if err != nil {
        log.Fatalln(err)
    }

    // Service2
    // gateway2 := service2.NewGateway(db)
    // provider2 := service2.NewProvider(gateway2)
    mockGateway2 := service2.NewMockGateway(service2.NewMockDB())
    provider2 := service2.NewProvider(mockGateway2)

    // Service3
    gateway3 := service3.NewGateway(acsr)
    provider3 := service3.NewProvider(gateway3)
    // mockGateway3 := service3.NewMockGateway(service3.NewMockDB())
    // provider3 := service3.NewProvider(mockGateway3)

    ctrl := &controller.Controller{}
    ctrl2 := controller.NewController2(provider2)
    ctrl3 := controller.NewController3(provider3)

    e.GET("/:message", ctrl.HandleMessage)
    e.GET("/people/:personID", ctrl2.HandlePersonGet)
    e.POST("/people", ctrl2.HandlePersonRegister)
    e.POST("/accounts", ctrl3.HandleAccountOpen)
    e.POST("/accounts/transfer", ctrl3.HandleMoneyTransfer)
}

データベースにDDLを適用してからWebサーバーを起動し、curlでリクエストを飛ばします。

$ go run cmd/http/main.go
$ curl -X POST -H 'Content-type: application/json' -d '{"ammount": 1000}' localhost:8080/accounts
{"ID":1,"Balance":1000}

$ curl -X POST -H 'Content-type: application/json' -d '{"ammount": 1000}' localhost:8080/accounts
{"ID":2,"Balance":1000}

$ curl -X POST -H 'Content-type: application/json' -d '{"fromId": 1, "toId": 2, "ammount": 300}' localhost:8080/accounts/transfer
{"from":{"ID":1,"Balance":700},"to":{"ID":2,"Balance":1300}}

$ curl -X POST -H 'Content-type: application/json' -d '{"fromId": 1, "toId": 2, "ammount": 300}' localhost:8080/accounts/transfer
{"from":{"ID":1,"Balance":400},"to":{"ID":2,"Balance":1600}}

$ curl -X POST -H 'Content-type: application/json' -d '{"fromId": 1, "toId": 2, "ammount": 300}' localhost:8080/accounts/transfer
{"from":{"ID":1,"Balance":100},"to":{"ID":2,"Balance":1900}}

$ curl -X POST -H 'Content-type: application/json' -d '{"fromId": 1, "toId": 2, "ammount": 300}' localhost:8080/accounts/transfer
"provider: balance is not sufficient - accountID: 1"

スクリーンショット 2019-11-17 20.35.31.png

スクリーンショット 2019-11-17 20.35.57.png

アプリケーションコアがインフラの知識を持つことなくトランザクションの実装ができました。

以上でサンプルアプリケーションの実装は終わりです。

google/wire による DI について

google/wire というツールを使うと DI を簡単に行うことができます。wire を利用した DI の実装のサンプルを GitHub リポジトリに含めているので興味がある方は覗いてみてください。

悩み1: トランザクションの管理はアプリケーション層?

記事の途中でも言及しましたが、トランザクションの管理をどこで行うかということは悩ましいです。エヴァンスはアプリケーション層でのトランザクション管理を提示していますが、場合によっては Gateway でトランザクションを管理したいこともあります。

例えば一対多の関係を持つオブジェクト群の保存処理です。例えば「注文」と「注文詳細」の登録です。

type Order struct {
    ID           int64
    TotalPrice   int
    OrderDetails []OrderDetail
}

type OrderDetail struct {
    ID            int64
    Product       Product
    Quantity      int
    SubTotalPrice int
}

type Product struct {
    ID    int64
    Name  string
    Price int
}

上記の Order オブジェクト(集約)をリレーショナルデータベースに登録しようと思ったら、order テーブルと order_detail テーブルに分けて保存することになるかと思います。この処理はもちろん同一トランザクション内で実行されます。

一方で、リレーショナルデータベースではなく本記事のサンプルプログラムのようにGoアプリケーションのメモリ上に保存する場合を考えてみます。この場合は OrderOrderDetail を分けて保存する必要はなく、Order オブジェクトをそのまま保存できます。この場合にはトランザクションを開始する必要はありません。

type MockDB struct {
    mu   sync.RWMutex
    data map[int64]Order
}

このケースでトランザクションを獲得して処理を行うかどうかは、アプリケーション層の関心の範囲内ではなく、インフラの関心の範囲です。よって、トランザクションの管理を Provider で行うのは好ましくありません。

『エリック・エヴァンスのドメイン駆動設計』「第6章 ドメインオブジェクトのライフサイクル」には次の記載があります。

トランザクションの制御をクライアントに委ねること。リポジトリはデータベースに対する挿入と削除を行うが、通常は何もコミットしない。例えば、保存した後にはコミットしたくなるが、おそらくクライアントには、作業ユニット(unit of work)を正しく開始し、コミットするためのコンテキストがある。トランザクション管理は、リポジトリが手を出さないでいる方が単純になる。

しかしながら、場合によっては Gateway でトランザクションを管理した方がいいのではないでしょうか。

追記1(2019/11/19)

リレーショナルデータベースの AUTOCOMMIT を有効にしているか無効にしているかで事情が少し変わる気がしてきました。AUTOCOMMIT を OFF に設定し、書き込み処理の際には必ず明示的にコミットしなければならない(RunInTransactionを呼ばなければならない)というポリシーの元で開発するなら、トランザクション管理は Provider に一元化できるかもしれません。

悩み2: 複数のデータストアに保存する場合

文字列や数値といったプリミティブなデータはリレーショナルデータベースに、画像などのバイナリデータはオブジェクトストレージ(GCS・S3)に保存するというケースは多いかと思います。もしくはDBにデータを保存しつつ、同一の処理の流れでメッセージングキューにもデータを送りたい場合もあります。

このように複数のデータストアが登場するとき、データストアごとにアダプター(Gateway)を作成すべきでしょうか?

『エリック・エヴァンスのドメイン駆動設計』「第6章 ドメインオブジェクトのライフサイクル」には次の記載があります。

完全にインスタンス化されたオブジェクトかオブジェクトのコレクションを戻すメソッドを提供すること。それによって、実際のストレージや問い合わせの技術をカプセル化すること。実際に直接的なアクセスを必要とする集約ルートに対してのみ、リポジトリを提供すること。

最後の一文から読み取れるのは、リポジトリは集約に関心を向けて作成されるものであって、ストレージに関心を向けて作成されるものではないということです。

「MysqlRepositoryImpl」「DynamoDBAccessor」のようにストレージごとに Gateway を作成するのではなく、単一の Gateway の中に複数のストレージへのアクセス手法をカプセル化するのがいいのかなと現時点では考えています。

おわりに

Go にはデファクトスタンダードと呼ばれるフレームワークがなく、ディレクトリ構成に悩んでいる方が多い印象です。僕もその1人でした。

アプリケーションの規模にも依るのでこれと言った正解はないかとは思いますが、常に学習を続けてメンテナンス性の高い
ソフトウェアを作っていきたいです。

以上で本記事は終わりです。

ソースコード全体(再掲)

参考

448
469
14

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
448
469