Goでクリーンアーキテクチャを試す

依存がなく、テスト可能であり、クリーン。

Uncle Bobのクリーンアーキテクチャの概念を読んだので、これを私はGoで実装してみたいと思います。このアーキテクチャは、自分たちの会社であるKurio – App Berita Indonesiaで使っていたものに似ていますが、少し違っています。大きな違いはなく、概念は一緒なのですが、フォルダ構造が違っています。

サンプルのプロジェクトとして、記事をCRUDで管理するリポジトリをhttps://github.com/bxcodec/go-clean-archにpushしてあります。


* 免責条項
ここで使われているどのライブラリあるいはフレームワークも、利用を特別推奨しているものではありませんので、ご自身あるいはサードパーティによる同じ機能のものと入れ替えることが可能です。

基本的な考え方

ご存知のように、クリーンアーキテクチャで設計する際の制約事項には以下のようなものがあります:

  1. フレームワークが独立している(依存がない)こと。そのアーキテクチャがフィーチャを豊富に持つようなライブラリの存在を前提としていないこと。これにより、自分の作るシステムを限定された制約条件の中に押し込めるのではなくて、フレームワークをツールかのように扱えるようになります。

  2. テスト可能であること。ビジネスルールが、UI、データベース、webサーバやその他の外部要素なしでテスト可能となっていること。

  3. UIの独立。UIをシステムの残りの部分に変更を与えずに変更できること。例えば、ビジネスルールの変更を伴わずにWebのUIをCLIで置き換えられる、など。

  4. データベースの独立。OracleやSQLサーバをMongo、BigTable、CouchDBやその他に交換できること。ビジネスルールがデータベースに束縛されないこと。

  5. 外部エージェントから独立していること。実際のところ、あなたが記述するビジネスルールは外部の世界について全く何も分かってないでしょう。

詳しくはhttps://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.htmlを参照してください。

つまりこれらの制約事項に基づくのであれば、どのレイヤも独立しておりテスト可能であることが必要です。

Uncle Bobのアーキテクチャには4つのドメインレイヤがあります:
* エンティティ
* ユースケース
* コントローラ
* フレームワークとドライバ

私のプロジェクトでも4つのドメインを使うことにしました:
* モデル
* リポジトリ
* ユースケース
* デリバリ

モデル

Bobのエンティティと同様に全ドメインレイヤで使います。このレイヤではどんなオブジェクトのStructやMethodも保持します。例:Article, Student, Book

Structの例は以下の通りです。

import "time"
type Article struct {
ID int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
view raw Article.go hosted with ❤ by GitHub

全てのエンティティやモデルここに保存されることになります。

リポジトリ

リポジトリは全てのデータベースハンドラを保存します。いかなるデータベース対するものでも問い合わせやCreate、Insertといった処理がここに保存されます。このドメインはデータベースへのCRUDのみを行います。ビジネスプロセスは発生しません。データベースに対するプレーンな関数に過ぎません。

同時に、このドメインはアプリケーションで使用するデータベースの選択を任されています。Mysql、MongoDB、MariaDB、Postgresqiその他何であっても、ここで決定されることになります。

ORMを使用する場合は、このレイヤで入力を制御し、直接ORMサービスへ入力を引き渡します。

マイクロサービスを呼び出す場合も、ここで処理されます。他のサービスに対するHTTPのリクエストを生成し、サニタイジングを行います。このレイヤは完全にリポジトリとして動かなければなりません。全ての入力と出力データを処理し、特殊化されたロジックは入り込まないようにします。

このリポジトリレイヤは接続状態にあるデータベースや、あるいは、もしあるのならばマイクロサービスに依存します。

ユースケース

本ドメインレイヤはビジネスプロセスのハンドラとして動きます。あらゆるプロセスがここで処理されます。このドメインレイヤがどのリポジトリレイヤを使うかを決めます。つまりデリバリレイヤへデータを提供する責務を負うことになります。データによる計算などの処理の全てがここで行われます。
ユースケースレイヤは、デリバリレイヤからのどんな入力(サニタイズ済み)にも応じ、受け取った入力をデータベースに保存したり、またはデータベースからフェッチするなどの処理を行っていきます。

ユースケースレイヤはリポジトリレイヤに依存します。

デリバリ

本ドメインレイヤはプレゼンタとして働きます。どのようにデータが提示されるかを決定します。REST API、HTMLファイル、gRPCなど、どのようなデリバリ形式にもなり得ます。
本レイヤはユーザからの入力にも応じます。入力をサニタイジングしユースケースレイヤに送ります。

サンプルのプロジェクトでは、REST APIをデリバリメソッドとして使っています。
クライアントがネットワーク上でリソースのエンドポイントを呼び出し、デリバリレイヤが入力またはリクエストを得て、それをユースケースレイヤに送ります。

本レイヤはユースケースレイヤに依存しています。

レイヤ間の通信

モデルを除いて、各レイヤはインターフェースを通して通信し合います。例えば、ユースケースレイヤにはリポジトリレイヤが必要ですが、どのように通信するのでしょうか。リポジトリは、コントラクトとなるようにインターフェースを提供し、通信するのです。

リポジトリのインターフェースの例

package repository
import models "github.com/bxcodec/go-clean-arch/article"
type ArticleRepository interface {
Fetch(cursor string, num int64) ([]*models.Article, error)
GetByID(id int64) (*models.Article, error)
GetByTitle(title string) (*models.Article, error)
Update(article *models.Article) (*models.Article, error)
Store(a *models.Article) (int64, error)
Delete(id int64) (bool, error)
}

ユースケースレイヤは、このコントラクトを使ってリポジトリに通信します。そしてリポジトリレイヤは必ずこのインターフェースを実装し、ユースケースが使えるようにしておきます。

ユースケースのインターフェースの例

package article
import (
"github.com/bxcodec/go-clean-arch/article"
)
type ArticleUsecase interface {
Fetch(cursor string, num int64) ([]*article.Article, string, error)
GetByID(id int64) (*article.Article, error)
Update(ar *article.Article) (*article.Article, error)
GetByTitle(title string) (*article.Article, error)
Store(*article.Article) (*article.Article, error)
Delete(id int64) (bool, error)
}

ユースケースと同じように、デリバリレイヤはこのコントラクトインターフェースを使います。そしてユースケースレイヤは必ずこのインターフェースを実装しなければいけません。

各レイヤの検証

ご存知のように、クリーンであるということは独立していることを意味します。たとえ他のレイヤが存在していなくても、各レイヤのテストが可能です。

  • モデルレイヤ
     本レイヤは、何かしらの関数またはメソッドが、何かしらのstructを宣言した場合のみテストします。
     テストは容易であり、他のレイヤから独立しています。

  • リポジトリ
     本レイヤのテスト手法としては、結合テストがオススメです。しかし、テストをモックで行うこともできます。私の場合はgithub.com/DATA-DOG/go-sqlmockをヘルパとして使い、MySQLのクエリのプロセスをモックしています。

  • ユースケース
     本レイヤはリポジトリレイヤに依存しているため、検証にはリポジトリレイヤが必要となります。そのため、定義済みのコントラクトインターフェースを元にmockeryを使ってモックしたリポジトリのモックアップを作らなければなりません。

  • デリバリ
     ユースケースがリポジトリレイヤに依存しているのと同じように、本レイヤはユースケースレイヤに依存しているため検証にはユースケースレイヤが必要となります。そしてユースケースレイヤも、定義済みのコントラクトインターフェースを元にmockeryを使ってモックしなければなりません。

私は、モックにvektra によるGoのためのmockeryを使っています。こちらはhttps://github.com/vektra/mockeryで確認できます。

リポジトリのテスト

本レイヤのテストでは、私は前述したとおりsql-mockを使って、クエリのプロセスをモックしています。私がgithub.com/DATA-DOG/go-sqlmockで使っている方法でもいいですし、もしくは似た関数を使った方法でもテストすることが可能でしょう。

  1. func TestGetByID(t *testing.T) {
  2. db, mock, err := sqlmock.New()
  3. if err != nil {
  4. t.Fatalf(“an error ‘%s was not expected when opening a stub
  5. database connection”, err)
  6. }
  7. defer db.Close()
  8. rows := sqlmock.NewRows([]string{
  9. id”, title”, content”, updated_at”, created_at”}).
  10. AddRow(1, title 1”, Content 1”, time.Now(), time.Now())
  11. query := SELECT id,title,content,updated_at, created_at FROM
  12. article WHERE ID = \\?”
  13. mock.ExpectQuery(query).WillReturnRows(rows)
  14. a := articleRepo.NewMysqlArticleRepository(db)
  15. num := int64(1)
  16. anArticle, err := a.GetByID(num)
  17. assert.NoError(t, err)
  18. assert.NotNil(t, anArticle)
  19. }

ユースケースのテスト

リポジトリレイヤに依存しているユースケースレイヤのテストのサンプルは以下の通りです。

package usecase_test
import (
"errors"
"strconv"
"testing"
"github.com/bxcodec/faker"
models "github.com/bxcodec/go-clean-arch/article"
"github.com/bxcodec/go-clean-arch/article/repository/mocks"
ucase "github.com/bxcodec/go-clean-arch/article/usecase"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestFetch(t *testing.T) {
mockArticleRepo := new(mocks.ArticleRepository)
var mockArticle models.Article
err := faker.FakeData(&mockArticle)
assert.NoError(t, err)
mockListArtilce := make([]*models.Article, 0)
mockListArtilce = append(mockListArtilce, &mockArticle)
mockArticleRepo.On("Fetch", mock.AnythingOfType("string"), mock.AnythingOfType("int64")).Return(mockListArtilce, nil)
u := ucase.NewArticleUsecase(mockArticleRepo)
num := int64(1)
cursor := "12"
list, nextCursor, err := u.Fetch(cursor, num)
cursorExpected := strconv.Itoa(int(mockArticle.ID))
assert.Equal(t, cursorExpected, nextCursor)
assert.NotEmpty(t, nextCursor)
assert.NoError(t, err)
assert.Len(t, list, len(mockListArtilce))
mockArticleRepo.AssertCalled(t, "Fetch", mock.AnythingOfType("string"), mock.AnythingOfType("int64"))
}

mockeryはリポジトリレイヤのモックアップを生成します。そのため、最初にリポジトリレイヤを完成させる必要はありません。リポジトリレイヤが実装されていなくても、先にユースケースを完成させることができます。

デリバリのテスト

デリバリのテスト方法は、データを送る方法によって左右されます。HTTPのREST APIを使っている場合は、Goの組み込みパッケージであるhttptestパッケージを使うことができます。

ユースケースに依存しているため、ユースケースのモックが必要です。リポジトリと同じように、私はmockeryを使ってユースケースをモックしてデリバリの検証を行っています。

  1. func TestGetByID(t *testing.T) {
  2. var mockArticle models.Article
  3. err := faker.FakeData(&mockArticle)
  4. assert.NoError(t, err)
  5. mockUCase := new(mocks.ArticleUsecase)
  6. num := int(mockArticle.ID)
  7. mockUCase.On(“GetByID”, int64(num)).Return(&mockArticle, nil)
  8. e := echo.New()
  9. req, err := http.NewRequest(echo.GET, “/article/” +
  10. strconv.Itoa(int(num)), strings.NewReader(“”))
  11. assert.NoError(t, err)
  12. rec := httptest.NewRecorder()
  13. c := e.NewContext(req, rec)
  14. c.SetPath(“article/:id”)
  15. c.SetParamNames(“id”)
  16. c.SetParamValues(strconv.Itoa(num))
  17. handler:= articleHttp.ArticleHandler{
  18. AUsecase: mockUCase,
  19. Helper: httpHelper.HttpHelper{}
  20. }
  21. handler.GetByID(c)
  22. assert.Equal(t, http.StatusOK, rec.Code)
  23. mockUCase.AssertCalled(t, GetByID”, int64(num))
  24. }

最終出力とマージ

全てのレイヤが完成し、テストを終えたら、ルートプロジェクト内のmain.goにある1つのシステムにマージします。ここで定義を行い、その環境に必要なものを生成し、全てのレイヤを1つにマージします。

例として私のmain.goをご覧ください。

package main
import (
"database/sql"
"fmt"
"net/url"
httpDeliver "github.com/bxcodec/go-clean-arch/article/delivery/http"
articleRepo "github.com/bxcodec/go-clean-arch/article/repository/mysql"
articleUcase "github.com/bxcodec/go-clean-arch/article/usecase"
cfg "github.com/bxcodec/go-clean-arch/config/env"
"github.com/bxcodec/go-clean-arch/config/middleware"
_ "github.com/go-sql-driver/mysql"
"github.com/labstack/echo"
)
var config cfg.Config
func init() {
config = cfg.NewViperConfig()
if config.GetBool(`debug`) {
fmt.Println("Service RUN on DEBUG mode")
}
}
func main() {
dbHost := config.GetString(`database.host`)
dbPort := config.GetString(`database.port`)
dbUser := config.GetString(`database.user`)
dbPass := config.GetString(`database.pass`)
dbName := config.GetString(`database.name`)
connection := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", dbUser, dbPass, dbHost, dbPort, dbName)
val := url.Values{}
val.Add("parseTime", "1")
val.Add("loc", "Asia/Jakarta")
dsn := fmt.Sprintf("%s?%s", connection, val.Encode())
dbConn, err := sql.Open(`mysql`, dsn)
if err != nil && config.GetBool("debug") {
fmt.Println(err)
}
defer dbConn.Close()
e := echo.New()
middL := middleware.InitMiddleware()
e.Use(middL.CORS)
ar := articleRepo.NewMysqlArticleRepository(dbConn)
au := articleUcase.NewArticleUsecase(ar)
httpDeliver.NewArticleHttpHandler(e, au)
e.Start(config.GetString("server.address"))
}
view raw main.go hosted with ❤ by GitHub

全てのレイヤが依存関係によって1つにマージされているのが分かります。

まとめ

  • 端的に図で表すと以下のようになります。

  • このプロジェクトで使われているライブラリは全て、あなたが使っているライブラリに置き換える可能です。クリーンアーキテクチャで重要な点は、どんなライブラリを使っていたとしても、アーキテクチャがクリーンであれば、テストが可能であり独立しているということです。

  • 本稿では、私のプロジェクトの組み立て方法について記述しました。反論や同意、または本稿を改善するための意見があればコメントを残し、シェアしてください。

サンプルプロジェクト

サンプルプロジェクトはhttps://github.com/bxcodec/go-clean-archで確認できます。

本プロジェクトに使ったライブラリは以下のとおりです:

  • Glide:パッケージの管理
  • github.com/DATA-DOG/go-sqlmockのgo-sqlmock
  • Testify:テスト環境
  • Echo Labstack(Go言語のWebフレームワーク):デリバリレイヤ
  • Viper:環境設定

クリーンアーキテクチャに関する参考記事:

本稿で説明が足りず、質問がある場合や、もっと説明が必要な場合などは、Linkedinメールでご連絡ください。お読みいただきありがとうございました。