20210308のGoに関する記事は4件です。

【Go言語】型アサーション vs リフレクション シンプルなベンチマーク

はじめに

Goでのリフレクションはパフォーマンスに影響するので注意と耳にするがどんなものかベンチマークを取る。

今回のサンプルケース

型としてinterface{}な実態が[]stringな値を引数に[]stringを返す関数。

リフレクションを使う場合と型アサーションを使う場合でベンチマークを取ってみた。

vs_test.go
package main

import (
    "reflect"
    "strconv"
    "testing"
)

var input []interface{}

func init() {
    for i := 1; i <= 10000; i++ {
        input = append(input, strconv.Itoa(i))
    }
}

// リフレクションを使う関数 は`interface{}`型で受け取りリフレクションをするので
// 柔軟に引数の値をさばくことができる。
func リフレクションを使う関数(target interface{}) []string {
    if target == nil {
        return nil
    }

    v := reflect.ValueOf(target)

    switch v.Kind() {
    case reflect.Slice:
        ret := make([]string, v.Len())
        for i := 0; i < v.Len(); i++ {
            x := v.Index(i)
            k := x.Kind()
            if k == reflect.Interface || k == reflect.String {
                s, ok := x.Interface().(string)
                if ok {
                    ret[i] = s
                }
            }
        }
        return ret
    case reflect.String: // stringが引数の場合はそれをスライスにして返す。
        return []string{v.String()}
    default:
        return nil
    }
}

// 型アサーションを使う関数 はスライスであることを決め打ちしたうえで`[]string`
// に変換して返す関数である。
func 型アサーションを使う関数(target []interface{}) []string {
    ret := make([]string, len(target))
    for i, r := range target {
        s, ok := r.(string)
        if ok {
            ret[i] = s
        }
    }
    return ret
}

func Benchmark_リフレクション(b *testing.B) {
    var target interface{}
    target = input
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        リフレクションを使う関数(target)
    }
}

func Benchmark_型アサーション(b *testing.B) {
    var target interface{}
    target = input
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        型アサーションを使う関数(target.([]interface{}))
    }
}

結果

速度において型アサーションがリフレクションよりも3倍強速かった。メモリ利用は差は無し。

この3倍をどう捉えるかはシステム要件次第であるが選択肢に入れられないほどパフォーマンスが悪いわけではない。

$ go test -bench . -benchmem
goos: darwin
goarch: amd64
Benchmark_リフレクション-4                          6013            204633 ns/op          163840 B/op          1 allocs/op
Benchmark_型アサーション-4                         18973             61017 ns/op          163842 B/op          1 allocs/op
PASS
ok      go-comparison/typeassert_vs_reflection        4.431s

感想

テストの関数に日本語を使うのは違和感が無いが、アプリケーションコードの関数を日本語にするのは変に感じる。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GORM まとめ

環境

# mysql --version
mysql  Ver 8.0.23 for Linux on x86_64 (MySQL Community Server - GPL)
$ go version
go version go1.15.7 linux/amd64
$ go mod graph
mymodule gorm.io/driver/mysql@v1.0.4
mymodule gorm.io/gorm@v1.21.3

GORM の動作

外部キーの作り方

次のような構造体をAutoMigrateに渡すことで、外部キーが自動生成されます。

type Parent struct {
    gorm.Model
}

type Child struct {
    gorm.Model
    ParentID uint
    Parent   Parent
}

スクリーンショット 2021-03-08 21.29.44.png

先程のコードは Child から Parent への参照の方向でした。Parent から Child への参照を行いたい場合は、次のように定義します。

type Parent struct {
    gorm.Model
    Child Child
}

type Child struct {
    gorm.Model
    ParentID uint
}

上記コードは「Parent がひとつの Child を持つ」場合の定義です。Parent が複数の Child を持つ場合は、Child ChildChild []Childと書き換えます。

type Parent struct {
    gorm.Model
    Child []Child
}

type Child struct {
    gorm.Model
    ParentID uint
}

外部キーの作り方を3つ紹介しました。すべて「ChildParentIDを持つ」という点で共通しており、異なるのは「参照の方向」と「単数形か複数形か」の2つだけであることがわかります。

多対多

次のように定義することで、多対多のテーブルを自動生成できます。

type User struct {
    gorm.Model
    Languages []Language `gorm:"many2many:user_languages;"`
}

type Language struct {
    gorm.Model
}

次の図が、自動生成されたテーブルとそれらの関係を表します。多対多の情報を持つ user_languages テーブルが作られていることがわかります。

スクリーンショット 2021-03-08 23.21.02.png

自動生成されたインデックスを見ると、Languageの ID から素早く情報が取得できるようになっていることがわかります。

スクリーンショット 2021-03-08 23.23.20.png

上記の定義とは対照にLanguage側でUsersを定義すると、自動生成されるインデックスがLanguageの ID からUserの ID に変わります。

type User struct {
    gorm.Model
}

type Language struct {
    gorm.Model
    Users []User `gorm:"many2many:user_languages;"`
}

スクリーンショット 2021-03-08 23.31.19.png

多対多のテーブルに直接対応する構造体は存在しないため、User(またはLanguage)構造体を使って間接的に操作する必要があります。

languages := []Language{{}, {}}
db.Create(&languages)
user := User{Languages: languages}
db.Create(&user)

user_languages テーブルは次のようになります。

スクリーンショット 2021-03-08 23.53.09.png

外部キーのフィールド情報の取得

次のコードを実行すると、Parentの情報を含んだChildが得られます。

var children []Child
db.Preload(clause.Associations).Find(&children)
log.Print(children)

clause.Associationsがない場合は、Parentの情報を持たないChildが得られます。

たとえclause.Associationsを使ったとしても、2つ深い情報は取得できません。

type Parent struct {
    gorm.Model
}

type Child struct {
    gorm.Model
    ParentID uint
    Parent   Parent
}

type Grandson struct {
    gorm.Model
    ChildID uint
    Child   Child
}
var grandson Grandson
db.Preload(clause.Associations).Find(&grandson)
log.Print(grandson.Child.Parent)    // 初期値が出力される

2つ以上深い情報を取得するためには、次のように記述します。

var grandson Grandson
db.Preload("Child.Parent").Find(&grandson)
log.Print(grandson.Child.Parent) // 期待した値が出力される

.Preload()に渡す文字列はテーブル名ではなく Go 言語での名前です。

外部キー制約の設定方法

次の場所に、外部キー制約を記述します。

type Parent struct {
    gorm.Model
}

type Child struct {
    gorm.Model
    ParentID uint
    Parent   Parent `gorm:"constraint:OnUpdate:RESTRICT,OnDelete:RESTRICT;"`
}

次の場所に外部キー制約を記述しても反映されないため、注意が必要です。

type Child struct {
    gorm.Model
    ParentID uint `gorm:"constraint:OnUpdate:RESTRICT,OnDelete:RESTRICT;"`
    Parent   Parent
}

外部キー制約のデフォルトの値はRESTRICTであるため、上記のような制約の記述は省略できます。

外部キーと削除

GORM の削除は論理削除であり、外部キー制約に違反するような削除を行っても正常に処理が終了してしまいます。これにより、テーブル結合時に空のフィールドになる恐れがあります。

論理削除ではなく物理削除を行う場合は、次のように.Unscoped()を使います。

var p Parent
db.Find(&p)
db.Unscoped().Delete(&p)

物理削除であるため、外部キー制約に違反した場合は次のようなエラーログが出力されます。

Error 1451: Cannot delete or update a parent row: a foreign key constraint fails (`root`.`children`, CONSTRAINT `fk_children_parent` FOREIGN KEY (`parent_id`) REFERENCES `parents` (`id`))

ネストしたトランザクション

GORM では、特別な設定なしにネストしたトランザクションを使うことができます。

次のコードを実行すると、1と3だけが反映されます。

db.Transaction(func(tx *gorm.DB) error {
    tx.Transaction(func(tx *gorm.DB) error {
        p := Parent{}
        tx.Create(&p)   // 1
        return nil
    })
    tx.Transaction(func(tx *gorm.DB) error {
        p := Parent{}
        tx.Create(&p)   // 2
        return errors.New("")
    })
    tx.Transaction(func(tx *gorm.DB) error {
        p := Parent{}
        tx.Create(&p)   // 3
        return nil
    })
    return nil  // 4
})

4の行をreturn errors.New("")などに書き換えると、すべての変更は破棄されます。

NULL

GORM はデフォルトで NULL 許容です。しかし、次のように nil を許容しない構造体のフィールドを定義すると、そのフィールドの値は 0 になります。

type Child struct {
    gorm.Model
    ParentID uint
}

NULL のニュアンスを正確にフィールドに反映させるためには、上記コードのuint*uintに変更します。これにより、値が NULL のフィールドには nil が入ります。

type Child struct {
    gorm.Model
    ParentID *uint
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Docker】 「Hello from Docker!」から半歩進んでみる

この記事の目的

  • Docker初心者が「Hello from Docker!」の半歩先に進む手助けをすることで、コンテナやDockerに慣れてもらう
  • Dockerを使って簡単なWebページを表示する

ターゲット

  • インストールだけはしたけど使い方が分からない人
  • 難しいことはいいから、とりあえず動かしたい人

前提条件

  • Dockerをインストールしている
  • hello-world(公式イメージ)などで動作確認が出来ている

※インストールがまだの方は以下の記事を参考にして下さい
Windows 10 64-bit: Pro, Enterprise, Educationの場合
Windows10 ProにDocker Desktopをインストールする

Windows 10 Homeの場合
Windows 10 HomeへのDocker Desktop (ver 3.0.0) インストールが何事もなく簡単にできるようになっていた (2020.12時点)

macOSの場合
Dockerインストール手順<macOS向け>

動作確認

インストールしてから時間が経って忘れてしまっている方のウォーミングアップも含めて、hello-worldを実行してみましょう。

hello-worldの実行
$ docker run hello-world

以下の通りに出力されれば成功です。
(すでに一度実行している場合は、Hello from Docker! から下が出力されます。)

hello-worldの結果
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
b8dfde127a29: Pull complete
Digest: sha256:89b647c604b2a436fc3aa56ab1ec515c26b085ac0c15b0d105bc475be15738fb
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

Webアプリを構築する

先ほどはDocker公式が用意しているhello-worldを使いました。
今回は、Goという言語でHello World!が表示されるWebアプリを構築しましょう。
構築手順は、大まかに以下の通りです。

  1. GoでWebアプリ作成
  2. Dockerfile作成
  3. DockerfileからDockerイメージを作成
  4. DockerイメージからDockerコンテナを作成・実行

Dockerfile、Dockerイメージ、Dockerコンテナの説明や関係性は後ほど簡単に説明しますが、先に詳しく知りたい方は以下のサイトがおすすめです。
Dockerイメージの理解を目指すチュートリアル

GoでWebアプリ作成

下記のサイトを参考に、画面にHello World!を表示するWebアプリを作成します。
Go / Gin で超簡単なWebアプリを作る

作成するWebアプリのディレクトリ構成は以下です。

フォルダ構成
任意のフォルダ
│
└─go
    │ ★ go.mod
    │
    └─src
        │ ★ main.go
        │
        └─templates
               ★ index.html

上から順に作成します。今回はGoの学習がメインではありませんので、コピーで構いません。

go.mod
module m

go 1.16

require github.com/gin-gonic/gin v1.6.3
main.go
package main

import (
    "github.com/gin-gonic/gin"
)

func main() {
    router := gin.Default()
    router.LoadHTMLGlob("/go/src/templates/*.html")

    router.GET("/", func(ctx *gin.Context) {
        ctx.HTML(200, "index.html", gin.H{})
    })

    router.Run()
}
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Go App in Docker</title>
</head>
<body>
    <h1>Hello World!</h1>
</body>
</html>

Dockerfileを作成

Dockerfileをgoフォルダと同階層に作成します。

Dockerfileの場所
任意のフォルダ
│ ★ Dockerfile
│
└─go
    │  go.mod
    │
    └─src
        │  main.go
        │
        └─templates
                index.html

Dockerfileってなに?

Dockerfileとは、Dockerイメージを作成するための定義ファイル(設計書みたいなもの)です。Dockerfileをビルド(build)するとDockerイメージを作成出来ます。

今回は、任意のフォルダに以下のDockerfileを作成してください。ファイル名を「Dockerfile」として下さい。

Dockerfile
# golang v1.16.0をベースイメージに指定
FROM golang:1.16.0
# Docker内での作業ディレクトリを指定
WORKDIR /go
# go.modをコピー
COPY ./go/go.mod ./
# Go modulesの設定
ENV GO111MODULE=on
# Go modulesを使用するため、GOPATHは削除
ENV GOPATH=
# Goの依存パッケージダウンロード
RUN go mod download
# Webアプリを作業ディレクトリにコピー
COPY ./go/src/ ./src/
# 待ち受けポートを8080に指定
EXPOSE 8080
# アプリ起動コマンド
CMD ["go", "run", "./src/main.go"]

Dockerfileについて詳しく知りたい方は折りたたみを読んで下さい。

Dockerfileを読み解く

Dockerfileを読み解く

Dockerfileにコメントもありますが、理解を深めるために詳しく説明していきます。

# golang v1.16.0をベースイメージに指定
FROM golang:1.16.0

FROMは、基本的にはDockerfileの最初に記述するコマンドで、タグを用いてベースイメージを指定します。ベースイメージとは、公式などがDocker Hubというリポジトリに公開している汎用的なDockerイメージです。
Dockerfileを作成する際に全てを定義するのは大変なので、自分の用途に適したベースイメージをDocker Hubから探して、2行目以降でカスタマイズします。
今回はGo言語でアプリを作成しますので、golang:1.16.0というタグを指定しています。
どのようなタグでベースイメージを取得できるかは、こちらで確認できます。

# Docker内での作業ディレクトリを指定
WORKDIR /go

Docker内での作業ディレクトリを指定します。
以降のCOPY命令の右側やCMD命令で./と書かれているものは、/goとなります。

# go.modをコピー
COPY ./go/go.mod ./

COPYは、ファイルパスやフォルダパスを指定してコピーするコマンドです。
今回は、Dockerファイルがあるフォルダ配下の./go/go.modを、Dockerの./(/go)にコピーしています。

# Go modulesの設定
ENV GO111MODULE=on
# Go modulesを使用するため、GOPATHは削除
ENV GOPATH=

ENVは、環境変数を設定する命令です。今回は、Goのアプリに必要な環境変数を設定しています。

# Goの依存パッケージダウンロード
RUN go mod download

RUNは、Dockerでコマンドを実行する命令です。デフォルトではshell形式で指定して、shell内で実行されます。(その他の方法で実行したい場合はこちら)
今回は、go.modに記述した依存パッケージをダウンロードしています。

# Webアプリを作業ディレクトリにコピー
COPY ./go/src/ ./src/

go.modの時と同様にsrcフォルダをコピーしています。
なんで分けてコピーしてるのかについては説明すると長くなってしまうので省きます。気になる方は以下のリンク先の「おまけ COPYのコツ」を読んでみてください。
DockerfileにてなぜADDよりCOPYが望ましいのか

# 待ち受けポートを8080に指定
EXPOSE 8080

EXPOSEは、待ち受けポートを指定する命令です。今回は8080としています。

# アプリ起動コマンド
CMD ["go", "run", "./src/main.go"]

CMDは、RUNと同様にコマンドを実行する命令です。違いは実行のタイミングです。

命令 実行のタイミング
RUN イメージを作成時に実行
CMD イメージ作成後、コンテナ実行時に実行

GOのWebアプリはコンテナが作成されてから実行して欲しいので、CMD命令で記述しています。
CMD命令は、Dockerfile内で1つしか記述出来ません。複数行記述した場合は、ビルド時に警告が出て最後のコマンドのみ実行されますので注意してください。

DockerfileからDockerイメージを作成

DockerfileというDockerイメージの設計書が完成したので、次はイメージを作成します。
任意のフォルダ直下(Dockerfileがある場所)で以下のコマンドを実行して下さい。

Dockerイメージ作成
$ docker build . -t go-app

コマンドの意味はこんな感じです。
docker build <Dockerfileのファイルパス> -t <名前:タグ>
今回は、Dockerfileと同階層でコマンドを実行しているので、.です。
名前というのは、Dockerイメージを識別するための名前です。タグは、バージョンなどを表すために付けます。今回は名前をgo-appと指定して、タグは省略したためlatestとなります。

では、作成したDockerイメージを確認しましょう。

Dockerイメージを確認
$ docker images

以下のような出力結果ならばDockerイメージが作成出来ています。

Dockerイメージ確認結果
REPOSITORY    TAG                 IMAGE ID       CREATED          SIZE
go-app        latest              463ccbe513cf   37 seconds ago   969MB
hello-world   latest              d1165f221234   1 hours ago     13.3kB
......(省略)

REPOSITORYがgo-appとなっています。Dockerイメージの「名前」や「イメージ名」と説明があったら、基本的にREPOSITORYに表示されている値を指します。

DockerイメージからDockerコンテナを作成・実行

いよいよWebアプリを動かせます!
以下のコマンドでDockerイメージからDockerコンテナを作成・実行しましょう。

Dockerコンテナ作成・実行
$ docker run -p 8080:8080 go-app

DockerイメージからDockerコンテナを作成・実行するときは、runコマンドを使用します。
今回のコマンドの意味は以下です。
docker run -p <ローカルのポート>:<Dockerコンテナのポート> <イメージ名>
-pは、ローカルとDockerコンテナ内部のポートを繋げるためのオプションです。

DockerコンテナとWebアプリの確認

最後にDockerコンテナとWebアプリの動作確認です。
Listening and serving HTTP on :8080と出力されたらlocalhost:8080にアクセスしてみましょう。
ブラウザの画面左上に「Hello World!」が表示されます!

後片付け

このままでは、Dockerコンテナが起動したままになってしまいます。またDockerイメージなども使わないならば削除しておいた方が良いです。(Dockerfileがあればまた作ることが出来ます。)

まずは、以下のコマンドを実行して起動中のDockerコンテナを確認します。

起動中Dockerコンテナ確認
$ docker ps

以下のように何らかの出力がある場合は、停止する必要があります。

起動中Dockerコンテナ確認結果
CONTAINER ID   IMAGE     COMMAND                  CREATED              STATUS              PORTS                    NAMES
ece7970bbafd   go-app    "go run /go/src/main…"   About a minute ago   Up About a minute   0.0.0.0:8080->8080/tcp   bold_khayyam

上記の出力でCONTAINER IDを確認して、go-appのDockerコンテナを停止します。

Dockerコンテナ停止
$ docker stop {CONTAINER ID}

今回の例では、{CONTAINER ID} = ece7970bbafd です。
もう一度起動中のDockerコンテナを確認します。

起動中Dockerコンテナを再度確認
$ docker ps
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

何も表示されていなければ停止しています。

次にDockerコンテナを削除します。-aのオプションを使うと、起動中のコンテナだけでなく停止中のコンテナを含めて確認出来ます。

Dockerコンテナを確認
$ docker ps -a

以下のようにSTATUSExitedで、先ほど停止したコンテナが出力されると思います。

Dockerコンテナ確認結果
CONTAINER ID   IMAGE     COMMAND                  CREATED          STATUS                      PORTS     NAMES
ece7970bbafd   go-app    "go run /go/src/main…"   12 minutes ago   Exited (2) 6 minutes ago              bold_khayyam

以下のコマンドを実行してコンテナを削除します。

Dockerコンテナを削除
$ docker rm {CONTAINER ID}

もう一度Dockerコンテナを確認します。

Dockerコンテナを再度確認
$ docker ps -a
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

何も表示されていなければDockerコンテナの後片付けは完了です。

イメージも同様に片付けましょう。
以下のコマンドを実行して、Dockerイメージを確認してください。

Dockerイメージを確認
$ docker images

go-appのイメージがあると思います。

Dockerイメージ確認結果
REPOSITORY    TAG                 IMAGE ID       CREATED             SIZE
go-app        latest              463ccbe513cf   About an hour ago   969MB
hello-world   latest              d1165f221234   41 hours ago        13.3kB
......(省略)

Dockerイメージも削除しましょう。

Dockerイメージを削除
$ docker rmi {IMAGE ID}

今回の例では、{IMAGE ID} = 463ccbe513cf です。
以下のように出力されれば後片付け完了です。

Dockerイメージ削除結果
Untagged: go-app:latest
Deleted: sha256:463ccbe513cff171877c411423843e2bbc9d6eb1f52e7f59b0f0e857b47fc714

まとめ

いかがだったでしょうか?慣れない作業が多く、疲れてしまった方も多いと思います。
確かにDockerをはじめとしたコンテナ技術は、簡単なシステムの作成にはオーバーヘッドが大きいのでありがたみを感じられなかったかもしれません。
しかし、複雑なシステムを扱うほど問題となる環境構築などを簡単にすることが出来ますので、これからも興味を持って頂ければと思います。

参考サイト

Windows10 ProにDocker Desktopをインストールする
Windows 10 HomeへのDocker Desktop (ver 3.0.0) インストールが何事もなく簡単にできるようになっていた (2020.12時点)
Dockerイメージの理解を目指すチュートリアル
[Docker] Dockerfile を作って Web アプリを構築する
Golang - Dockerfileの最小構成
Go / Gin で超簡単なWebアプリを作る
DockerfileにてなぜADDよりCOPYが望ましいのか

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Go】厳密なClean Architectureとその妥協案

Clean Architectureとは

Clean Architecture(クリーンアーキテクチャ)とは,レイヤに内側と外側の関係性を持たせるアーキテクチャである.
「外側のレイヤは内側のレイヤだけに依存する」というルールを守ることによって,アプリケーションから技術を分離することが目的である.

厳密なClean Architecture

CleanArchitecture.jpg
上の図で提案されているレイヤ構造をもとにしてpackage構成に落とし込んだものが以下の図である.
スクリーンショット 2021-03-06 18.16.36.png
ここで,実線は依存,点線は実装を表している.
このpackage構成について,サンプルコード付きの解説をここでしているので,もし興味があれば読んでください.

しかし,このpackage構成では次のような問題がある.
1. データの整合性を保つために複数モデルを扱うような処理はどこに置くのか?
2. ドメインモデルに持たせるべきではない処理はどこに置くのか?
それぞれ,詳しく説明していく.

データの整合性を保つために複数モデルを扱うような処理

ゲームでのアイテム購入などがその一例である.この場合,
・ユーザがもつコイン消費 (usersテーブル)
・ユーザがもつアイテム更新 (users_to_itemsテーブル)
はトランザクション処理としてまとまっている必要がある.(まとまっていなければ,「コインを消費したがアイテムが増えていない」のような状態になる可能性がある.)
このようなデータの整合性を保つために複数モデルを扱うような処理を置くべきpackageが存在しないのである.

ドメインモデルに持たせるべきではない処理

あるアイテムをユーザが持っているかの判定などがその一例である.この場合,
・あるアイテムをDBから取得する
・取得できたかどうかを判別し,結果を返す
のように,repository+αの処理をする必要がある.
このようなドメインモデルに持たせるべきではない処理を置くべきpackageが存在しないのである.

妥協案v1

上で述べたような問題を解消するために,今までusecaseレイヤにあったrepositoryentityレイヤに移し,modelrepositoryを操作するserviceを追加する.
これによって,adapterレイヤからentityレイヤに,usecaseレイヤを飛ばした依存関係ができてしまうが,そこは妥協する.
 スクリーンショット 2021-03-07 0.02.08.png
このようなpackage構成にすることで,「データの整合性を保つために複数モデルを扱うような処理」や「ドメインモデルに持たせるべきではない処理」を置くべきserviceが誕生するのである.

トランザクション処理の実装

このpackage構成の場合,トランザクション処理は次のように実装すればよい.
1. トランザクションオブジェクトを生成する
2. トランザクションオブジェクトをContextに入れて,DB操作がまとまった関数を実行する
3. 各DB操作は,Contextからトランザクションオブジェクトを取得し,実行する
3. errornilであればcommitnilでなければrollbackする

先ほど具体例として出した「ユーザのコイン消費とアイテム更新」の実装を,コードを用いて説明していく.
まず,repositoryに,トランザクション用のinterfaceを作る.

entity/repository/transaction.go
package repository

type TxRepository interface {
    DoInTx(ctx context.Context, f func(ctx context.Context) (interface{}, error)) (interface{}, error)
}

そして,このinterfaceをgatewayで実装する.

adapter/gateway/transaction.go
package gateway

type TxRepository struct {
    conn   *sql.DB
}

// GetDBConn はTxRepositoryが保持しているConnectionを返します.
func (tr *TxRepository) GetDBConn() *sql.DB {
    return tr.conn
}

// NewTxRepository は*sql.DBを受け取り,TxRepositoryを返します.
func NewTxRepository(conn *sql.DB) repository.TxRepository {
    return &TxRepository{
        conn:   conn,
    }
}

// DoInTx は,トランザクションオブジェクトを生成し,contextに入れて,次の関数を実行し,errorに応じて適切にrollbackやcommitを行います.
func (tr *TxRepository) DoInTx(ctx context.Context, f func(ctx context.Context) (interface{}, error)) (interface{}, error) {
    // txを生成する
    conn := tr.GetDBConn()
    tx, err := conn.Begin()
    if err != nil {
        return nil, fmt.Errorf("begin transaction: %w", err)
    }
    // txをcontextに入れて次の関数を実行する
    // SetTxのような関数は存在していないので,各自実装する必要あり
    ctx = context.SetTx(ctx, tx)
    v, err := f(ctx)
    if err != nil {
        _ = tx.Rollback()
        return v, fmt.Errorf("rollback: %w", err)
    }
    if err := tx.Commit(); err != nil {
        _ = tx.Rollback()
        return v, fmt.Errorf("failed to commit: rollback: %w", err)
    }
    return v, nil
}

その後,まとめたい処理をserviceに書き,DoInTxを用いればよい.

entity/service/user.go
package service

type UserService interface {
    UpdateCoinAndItemTx(ctx context.Context, userID string, coin int32, itemID string) error
}

type User struct {
    userRepository repository.UserRepository
    itemRepository repository.ItemRepository
    txRepository   repository.TxRepository
}

// NewUserService は,UserServiceを返します.
func NewUserService(txRepository repository.TxRepository, userRepository repository.UserRepository, itemRepository repository.ItemRepository) UserService {
    return &User{
        txRepository:   txRepository,
        userRepository: userRepository,
        itemRepository: itemRepository,
    }
}

// updateCoinAndItem は,ユーザのコイン消費とアイテム更新を行います.
func (u *User) updateCoinAndItem(userID string, coin int32, itemID string) func(ctx context.Context) (interface{}, error) {
    return func(ctx context.Context) (interface{}, error) {
        userRepository := u.userRepository
        itemRepository := u.itemRepository
        // コイン消費
        // userRepository.UpdateUserCoinByPrimaryKeyTx では,contextからtxを取得して実行する
        if err := userRepository.UpdateUserCoinByPrimaryKeyTx(ctx, userID, coin); err != nil {
            return nil, err
        }
        // アイテム更新
        // itemRepository.InsertUserItemTx では,contextからtxを取得して実行する
        if err := itemRepository.InsertUserItemTx(ctx, userID, itemID); err != nil {
            return nil, err
        }
        return nil, nil
    }
}

// UpdateUserAndItemTx は,トランザクション内で,ユーザのコイン消費とアイテム更新を行います.
func (u *User) UpdateCoinAndItemTx(ctx context.Context, userID string, coin int32, itemID string) error {
    txRepository := u.txRepository
    if _, err := txRepository.DoInTx(ctx, u.updateCoinAndItem(userID, coin, itemID)); err != nil {
        return err
    }
    return nil
}

このようにserviceに実装しておくことで,interactorではトランザクションのことを考えずに,ただ呼び出すだけで良くなる.

usecase/interactor/user.go
package interactor

type User struct {
    OutputPort  port.UserOutputPort
    UserService service.UserService
}

func NewUserInputPort(outputPort port.UserOutputPort, userService service.UserService) port.UserInputPort {
    return &User{
        OutputPort:  outputPort,
        UserService: userService,
    }
}

func (u *User) BuyItem(ctx context.Context, userID string, coin int32, itemID string) {
    op := u.OutputPort
    us := u.UserService
    if err := us.UpdateCoinAndItemTx(ctx, userID, coin, itemID); err != nil {
        // エラー用のアウトプットポートを呼び出す
        op.RenderError()
        return
    }
    // 正常用のアウトプットポートを呼び出す
    op.Render()
}

outputPortの必要性

次に,outputPortを採用するべきかということについて考えてみる.
通常のMVCなどでは,controllermodelを操作することで,DB操作やドメインロジックを実行し,その返り値を受け取り出力する
では,Clean Architectureでも,controllerがinputPortを実行し,返り値を受け取って出力してもよいのだろうか?
これを考えるために,outputPortを採用することでのメリット・デメリットを考えてみる.

outputPortのメリット

outputPortを採用することで,入力と出力が分離でき,interactorが出力を管理できるといったメリットが存在する.
例えば,interactorentityレイヤを操作した結果によって,出力したり出力しなかったりする場合を考える.もし仮に,controllerinputPortから返り値を受け取り出力を行う構成だとすれば,「出力するかしないか」の判断をcontrollerがすることになる.しかし,その責務はcontrollerが持つには多すぎるように思われる.むしろ,そのような責務はビジネスロジックを実装しているinteractorが持つべきなのではないだろうか.

outputPortのデメリット

outputPortを採用すると,outputPortの生成にhttp.Responswriter,もしくはそれに相当するものが必要になる.したがって,routingの設定部分でPortの初期化を行えず,controller内でPortの初期化を行う必要が生じてしまう.よって,
・リクエストの度にPortの初期化処理を行わなければならない
・handlerが増えた場合にも全く同じ処理を書かなければならない
というようなデメリットが生じてしまう.

妥協案v2

以上のメリット・デメリットを天秤にかけた上で,デメリットの方が大きければ次のようなpackage構成を採用すればよい.スクリーンショット 2021-03-07 0.34.32.png
また,Webアプリケーションフレームワークによっては,controllerで出力を行なわなければならず,usecaseレイヤのinteractorで出力を管理することができないものもある.そのようなフレームワークを使う場合には,上の図のようなpackage構成とならざるをえない.

まとめ

様々なpackage構成を紹介したが,アーキテクチャに正解はないので,作成するアプリや用いる技術,開発メンバーなどを考慮して,上手にpackage構成を考えていくことが大切である.

参考文献

この記事は以下の情報を参考にして執筆しました.
pospomeのサーバサイドアーキテクチャ(PDF版)
Clean Architecture で実装するときに知っておきたかったこと
The Clean Architecture
クリーンアーキテクチャのUsecaseはなぜControllerへ値を返すのではなくOutput PortとしてPresenterを呼び出すのか

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む