おひとり

できる限りひとりで楽しむための情報やプログラミング情報など。

【Go】アーキテクチャを考慮してWebAPIサーバのサンプルを作ってみました

GoでWebサーバなどを書く場合、その構造をどうするか「かなり」悩みます。
そこで、これまで見てきたコードも参考にしつつ、「ひな形」といえるようなWebAPIサーバを作ってみました。
何か新しいサーバを書く際は、この構造を元にライブラリを追加するとか、レイヤーを削るとかの調整を行えるものを目指しました。

なお、概念そのものは他の言語でも通用するかと思います。
Go書かない方も、アーキテクチャに興味がある方の参考になれば嬉しいです。
(他の言語を書かれる方へのご注意:Goはダックタイピングな言語ですのでコードを読む場合はご注意ください。)

TL;DR

リポジトリを参照してください。

github.com

APIの仕様

今回作成したサンプルアプリはREST APIで、メモを保存した、取得したりするだけのCRUDのAPIです。(ただし、UDはありません。)
エンドポイントは3つだけです。

パス メソッド 概要
/memo GET メモの一覧を取得する。
/memo/:memo_id GET :memo_idのメモを取得する。
/memo POST 新しいメモを追加する。

※結果は全てJSONで返す。

リクエストやレスポンスの詳細はリポジトリをご覧ください。

github.com

システム構成

システム構成は単純で、GoのWebサーバとRDB(PostgreSQL)があるだけです。

システム構成

利用ライブラリ

ライブラリは必要最小限にしようと思っていたのですが、割と使ってしまいました。。。
とはいえ、どれも定番かもしれません。

ライブラリ 利用用途
validator バリデーションするため。
migrate テスト時のテストデータを投入のため。
uuid ユニットテストのテストデータを生成するため。
echo HTTPリクエストのルーティングやリクエストの扱いのため。
logrus ロギングのため。
viper 環境変数から設定値を取得するため。
testify テストの記述を効率化するため。
gorm RDBのORMとして。
mockery モックを自動生成するため。

ライブラリは開発規模に応じて削っても良いと思います。
特に、ORMは小規模の場合は必要ないかな?と思います。そもそもORM嫌いな人もいますし。

また、DI(Dependency Injection)系のライブラリがないことに気付いたかもしれません。
実はDIライブラリとしてwireを使う前提で進めていました。
しかし、いくら拡張可能性を考えてつくる、、、といっても今回のサンプルは小さいものになります。そのため、書いている途中でアホらしくなってしまい(口悪いですが)、全部消して手動でDIを書くことにしています。

気付けば早すぎる最適化が。。。。

wireを使う場合、プリミティブ型にも別に説明性の高い型名(String -> RDBConnectionStringなど)を付ける必要があるなど、結構考慮が必要になります。小さいモノをつくる時はそっちのコストが重いため、今回の場合はナンセンスだと考えてしまいました。

プロジェクトの構造

main.go以外のファイルは表示していません。必要なディレクトリのみ表示しています。

.
├── main.go
├── config
├── model
├── it
├── infra
│   ├── db
│   └── server
└── memo
    ├── handler
    ├── mocks
    ├── repository
    └── usecase

では、それぞれについて書いていきます。

モデル層: model

/modelはモデル(データの表現)を格納します。
今回は、Memoしかありません。

// Memo represents memo
type Memo struct {
    ID        int        `json:"id" db:"id"`
    Title     string     `json:"title" db:"title"`
    Content   string     `json:"content" db:"content" validate:"required"`
    CreatedAt *time.Time `json:"created_at" db:"created_at"`
    UpdatedAt *time.Time `json:"updated_at" db:"updated_at"`
    DeletedAt *time.Time `json:"deleted_at" db:"deleted_at"`
}

※完全なコードはこちら

なお、今回作成したモデルはタグをみて分かるとおり、DBテーブルの情報、およびクライアントへ返却するデータ型であるJSONの情報を含んでいます。
つまり、Modelがシステム内の共通言語の役割も担っています。

Modelはシステム内の共通言語の役割を持つ

もう少し大きいシステムを考える場合は、DBテーブルとシステム内部で用いるデータ構造は分離した方がいいかもしれません。
例えば、Entityという概念を導入して、システム内部ではEntityを共通言語としてやりとりする、等が考えられます。

その場合、次のような処理の流れになるかと思います。

Entityを導入した場合の流れ

Entityの概念を導入することにより、DBテーブルの構成を変更してもRepository層のModelのみを変更すればよく、システム全体への影響を抑えられます。
今回はそのケースは考慮しないため、Entityは導入していません。

外部サービスなどのインフラを表現:infra

  • PostgreSQLなどのRDB
  • RedisなどのNoSQL系データベース
  • RabbitMQなどのキューイングサービス
  • 外部API

などなど、外部システムはインフラと分類できます。これらを初期化、操作する処理は/infraに格納します。

例えば、/infra/db/postgres.goにはPostgreSQLを利用するためのオブジェクトを取得する関数が定義されています。

package db

import (
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

var psqlDB *gorm.DB = nil

func NewPostgresDB(dsn string) (*gorm.DB, error) {
    if psqlDB != nil {
        return psqlDB, nil
    }

    var err error
    psqlDB, err = gorm.Open(postgres.Open(dsn))
    if err != nil {
        return nil, err
    }

    return psqlDB, nil
}

※GitHubで見るにはこちら

また、今回はMemoAPIサーバを表す構造体もインフラとみなし、/infra/serverに格納しています。
これについては、/infraの中ではなく、プロジェクト直下に/serverとして切り出すアイディアもあります。この辺は好みかな、と思います。

/infra/server/server.goには、以下のように今回の開発対象となるMemoAPIサーバを表す構造体が定義されています。

// Server represents server.
type Server struct {
    DB          *gorm.DB
    Host        string
    Port        int
    ServerReady chan<- interface{}
    echo        *echo.Echo
    shutdown    chan interface{}
}

// NewServer returns new Server object.
func NewServer(port int, host string, db *gorm.DB, serverReady chan<- interface{}) *Server {
...
}


// Start starts server.
func (s *Server) Start() {
...
}

// Shutdown shutdowns the server.
func (s *Server) Shutdown() {
...
}

※完全なコードはこちら

アプリの設定を管理する: config

/configには、アプリの設定を管理する機能を格納しています。

以下は/config/config.goの一部です。

// config represents config of this service.
type Config struct {
    appPort   int
    appHost   string
    RdbConfig *RdbConfig
}

// rdbConfig represents config of relational database.
type RdbConfig struct {
    port     int
    host     string
    user     string
    password string
    database string
}

// Load loads config.
func Load() Config {
  // 環境変数から設定を読み込む処理。
}


/* 各種getter */

※完全なコードはこちら

なお、設計には直接関係ありませんが、Config構造体のフィールドは全てprivateにしています。
これは、Configのオブジェクトが使い回されることを考慮しているためです。
※何かの不手際でConfigの中身が書き換えられるのを防止し、安心して他の関数に引き渡したりできる。

インテグレーションテスト: it

/itにはインテグレーションテストを格納しています。
今回はE2Eテストとして、実際にサーバを起動し、リクエストを送っています。

そのため、SetupSuiteはやや複雑です。
※今回はテスト用のライブラリとしてtestifyを使っています。suiteはtestifyに含まれる、テストを表現するオブジェクトです。SetupSuite()はそのテスト開始時に1度だけ実行され、suiteを初期化します。

...
func (s *e2eTestSuite) SetupSuite() {
    var err error

    s.config = config.Load()
    s.db, err = db.NewPostgresDB(s.config.RdbConfig.ConnectionString())
    s.Require().NoError(err)

    // go-migrateを使って、E2Eテスト用のデータベースにテストデータを投入する。
    s.dbMigration, err = migrate.New("file://../initdb/db-test", s.config.RdbConfig.ConnectionString())
    s.Require().NoError(err)
    if err := s.dbMigration.Up(); err != nil && err != migrate.ErrNoChange {
        s.Require().NoError(err)
    }

    serverReady := make(chan interface{}, 1)
    s.server = server.NewServer(s.config.AppPort(), s.config.AppHost(), s.db, serverReady)

    go s.server.Start()
    <-serverReady
}

func (s *e2eTestSuite) TestCreateMemo() {
    reqBody := `{"title": "Hello, Go", "content": "Hello, World!"}`
    req, err := http.NewRequest(
        "POST",
        fmt.Sprintf("http://localhost:%d/memo", s.config.AppPort()),
        strings.NewReader(reqBody),
    )
    s.Require().NoError(err)
    req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)

    client := http.Client{}
    resp, err := client.Do(req)
    s.Require().NoError(err)
    // 以下省略:リクエストの検証をするコード
}

...

※完全なコードはこちら

今回は小規模でしたので、E2EテストもGoで書きました。
しかし、E2EテストはGoではどうしてもコードが長くなってしまいました。

そのため、

  • Goで書いたサーバをあらかじめ起動しておく(go run main.goなどで)
  • 起動したサーバに対して、Python等で書いたE2Eテストを実行する

が書きやすいと感じます。
個人的にはJSONや文字列データの検証などはPythonなどのスクリプト言語の方が書きやすいです。

ビジネスロジック: memo

/memoには今回のMemoAPIのビジネスロジックが格納されています。
ファイルを含めた構造は次のようになっています。

.
├── handler
│   ├── handler_test.go
│   ├── memo_http.go
│   └── response.go
├── mocks
│   ├── Repository.go
│   └── Usecase.go
├── repository
│   └── repository_postgres.go
├── repository.go
├── usecase
│   └── memo.go
└── usecase.go

依存関係は次のようになっています。

依存関係

HTTPを処理する(Controller): handler

/memo/handlerには、HTTPハンドラが定義されています。
フレームワークではControllerと呼ばれる機能がこちらに該当します。

/memo/handler/memo_http.goは次のようになっています。
echoを使っています。

// MemoHTTPHandler represents memo http handler.
type MemoHTTPHandler struct {
    usecase memo.Usecase
}

// NewMemoHTTPHandler returns new instance of MemoHTTPHandler.
func NewMemoHTTPHandler(usecase memo.Usecase) *MemoHTTPHandler {
    return &MemoHTTPHandler{usecase: usecase}
}


// HandleGetAllMemo handles get all memos.
func (h *MemoHTTPHandler) HandleGetAllMemo(c echo.Context) error {
    res, err := h.usecase.GetAllMemo(context.Background())
    if err != nil {
        return c.JSON(http.StatusInternalServerError, &Response{
            Message: err.Error(),
            Data:    nil,
        })
    }

    return c.JSON(http.StatusOK, &Response{
        Message: "success",
        Data:    res,
    })
}

※完全なコードはこちら

MemoHTTPHandlerUsecaseに依存していて、ビジネスロジックはこのインタフェースを実装したオブジェクトを呼び出します。

/memo/usecase.goは次の通りです。

// Usecase defines memo usecase contract.
type Usecase interface {
    CreateMemo(ctx context.Context, m *model.Memo) error
    GetMemoByID(ctx context.Context, id int) (*model.Memo, error)
    GetAllMemo(ctx context.Context) ([]*model.Memo, error)
}

※完全なコードはこちら

Usecase層:usecase

先ほどは/memo/usecase.goを見ました、この実装は/memo/usecase/memo.goにあります。

// MemoUsecase implements memo usecase.
type MemoUsecase struct {
    repository memo.Repository
    validate   *validator.Validate
}

// NewMemoUsecase returns new instance of Usecase.
func NewMemoUsecase(repository memo.Repository) *MemoUsecase {
    return &MemoUsecase{
        repository: repository,
        validate:   validator.New(),
    }
}

// CreateMemo creates a new memo.
func (u *MemoUsecase) CreateMemo(ctx context.Context, m *model.Memo) error {
    if err := u.validate.Struct(m); err != nil {
        return err
    }
    return u.repository.CreateMemo(ctx, m)
}

// GetMemoByID gets memo by given id.
func (u *MemoUsecase) GetMemoByID(ctx context.Context, id int) (*model.Memo, error) {
    return u.repository.GetMemoByID(ctx, id)
}

// GetAllMemo gets all memos stored in repository.
func (u *MemoUsecase) GetAllMemo(ctx context.Context) ([]*model.Memo, error) {
    return u.repository.GetAllMemo(ctx)
}

※完全なコードはこちら

今回は単なるCRUDアプリですので、Repository層のコードを呼び出しているだけの味気ないコードです。
Usecaseには依存性の逆転と言うことで、Repositoryインタフェースに依存しています。
それが/memo/repository.goにあります。

// Repository defines memo repository contract.
type Repository interface {
    CreateMemo(ctx context.Context, a *model.Memo) error
    GetMemoByID(ctx context.Context, id int) (*model.Memo, error)
    GetAllMemo(ctx context.Context) ([]*model.Memo, error)
}

※完全なコードはこちら

では、次にrepository層の実装を見てみます。

Repository層:repository

/memo/repository/repository_postgres.goは先ほどみたRepositoryインターフェースの実装です。

// PostgresMemoRepository implements memo repository.
type PostgresMemoRepository struct {
    db *gorm.DB
}

// NewPostgresMemoRepository returns new instances of postgres memo repository.
func NewPostgresMemoRepository(db *gorm.DB) *PostgresMemoRepository {
    return &PostgresMemoRepository{db: db}
}

// GetAllMemo retrieves all memos.
func (r *PostgresMemoRepository) GetAllMemo(ctx context.Context) ([]*model.Memo, error) {
    memos := []*model.Memo{}
    if err := r.db.WithContext(ctx).Find(&memos).Error; err != nil {
        return nil, err
    }
    return memos, nil
}

※完全なコードはこちら

PostgresMemoRepositoryRepositoryインタフェースの実装ですが、データがPostgreSQLに保存されていることを知っています。
modelを返すことで、データの保存先を抽象化しています。

なお、複数のテーブルを利用する予定で、かつRepositoryを様々なUsecaseで使い回す場合はRepository層はプロジェクト直下に配置するのがよいでしょう。

Repository層をプロジェクト直下に配置する場合

ユニットテストで使うモック: mocks

/memo/mocksには、ユニットテストで使えるモック用の構造体が格納されています。
例えば、/memo/handler/handler_test.goでは、次のようにモックを使います。

func (s *memoHandlerTestSuite) TestMemoHandler_HandleGetAllMemo() {
    // 省略:テストの諸々の準備

    // DI
    mockUsecase := &mocks.Usecase{}
    mockUsecase.On("GetAllMemo", mock.Anything).Return(expected, nil)
    h := handler.NewMemoHTTPHandler(mockUsecase)

    // 省略:各種検証等
}

※完全なコードはこちら

/mocks配下にはRepository.goおよびUsecase.goの2つのファイルがありますが、これはmockeryが生成したものです。

├── mocks
│   ├── Repository.go
│   └── Usecase.go

mockeryはテスト用のライブラリであるtestifyと合わせて使います。

例えば、/memoディレクトリの中で以下のようなコマンドを実行することで、/memo/usecase.goに定義されたUsecaseのモックを生成できます。

mockery --name=Usecase

mocksディレクトリは自動で生成されます。

DI(Dependency Injection)

DIは今回は/infra/server/server.goの中で行っています。

func (s *Server) setupRoute() {
    e := echo.New()
    repo := repository.NewPostgresMemoRepository(s.DB)
    usecase := usecase.NewMemoUsecase(repo)
    memoHandler := handler.NewMemoHTTPHandler(usecase)

    e.POST("/memo", memoHandler.HandleCreateMemo)
    e.GET("/memo", memoHandler.HandleGetAllMemo)
    e.GET("/memo/:memo_id", memoHandler.HandleGetMemoByID)

    s.echo = e
}

※完全なコードはこちら

今回は十分小さいので、複雑なコードを追加して見通しを悪くしたくなかったため、Serverの中に書きました。
通常はもっと大きいアプリになると思います。次の2つのアイディアがあります。

DIのアイディア:Providerを定義する

以下のようなProviderを/infra/server/server.goに定義します。

// HTTPHandlerProvider provides http handlers.
type HTTPHandlerProvider struct {
    memo handler.MemoHTTPHandler
}

// NewServer returns new Server object.
func NewServer(/* 省略 */, handlers *HTTPHandlerProvider) *Server {
...
}

そして、main.goHTTPHandlerProviderのオブジェクトを生成し、NewServer()呼び出し時に渡します。
こうすることで、シンプルなDIが実現できます。

DIのアイディア:DIライブラリを使う

wireなどのDIライブラリを使います。
ただ、前の章でも書いたとおり、DIライブラリを使うにはいくつかルールやベストプラクティスを守る必要があります。
そのため、設計段階において考慮すべき点が増えるため注意する必要があります。

まとめ

今回はこれまで見てきたプロジェクトなどを元に、小さいGoのWebAPIサンプルを作成してみました。
設計はプロジェクトの保守性を決める重要な要素というのもあり、考えるのに工数がかかります。
今回作成したプロジェクトを基本形としながら、規模やシステムの特性に応じた構造に調整していければよいと考えています。

非常に長文記事になってしまいましたが、ここまで読んでくださった方にとって少しでも参考になれば嬉しいです。

Go