GoでWebサーバなどを書く場合、その構造をどうするか「かなり」悩みます。
そこで、これまで見てきたコードも参考にしつつ、「ひな形」といえるようなWebAPIサーバを作ってみました。
何か新しいサーバを書く際は、この構造を元にライブラリを追加するとか、レイヤーを削るとかの調整を行えるものを目指しました。
なお、概念そのものは他の言語でも通用するかと思います。
Go書かない方も、アーキテクチャに興味がある方の参考になれば嬉しいです。
(他の言語を書かれる方へのご注意:Goはダックタイピングな言語ですのでコードを読む場合はご注意ください。)
TL;DR
リポジトリを参照してください。
APIの仕様
今回作成したサンプルアプリはREST APIで、メモを保存した、取得したりするだけのCRUDのAPIです。(ただし、UD
はありません。)
エンドポイントは3つだけです。
パス | メソッド | 概要 |
---|---|---|
/memo | GET | メモの一覧を取得する。 |
/memo/:memo_id | GET | :memo_id のメモを取得する。 |
/memo | POST | 新しいメモを追加する。 |
※結果は全てJSONで返す。
リクエストやレスポンスの詳細はリポジトリをご覧ください。
システム構成
システム構成は単純で、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がシステム内の共通言語の役割も担っています。
もう少し大きいシステムを考える場合は、DBテーブルとシステム内部で用いるデータ構造は分離した方がいいかもしれません。
例えば、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, }) }
※完全なコードはこちら。
MemoHTTPHandler
はUsecase
に依存していて、ビジネスロジックはこのインタフェースを実装したオブジェクトを呼び出します。
/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 }
※完全なコードはこちら。
PostgresMemoRepository
はRepository
インタフェースの実装ですが、データがPostgreSQLに保存されていることを知っています。
model
を返すことで、データの保存先を抽象化しています。
なお、複数のテーブルを利用する予定で、かつRepositoryを様々なUsecaseで使い回す場合は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.go
でHTTPHandlerProvider
のオブジェクトを生成し、NewServer()
呼び出し時に渡します。
こうすることで、シンプルなDIが実現できます。
DIのアイディア:DIライブラリを使う
wireなどのDIライブラリを使います。
ただ、前の章でも書いたとおり、DIライブラリを使うにはいくつかルールやベストプラクティスを守る必要があります。
そのため、設計段階において考慮すべき点が増えるため注意する必要があります。
まとめ
今回はこれまで見てきたプロジェクトなどを元に、小さいGoのWebAPIサンプルを作成してみました。
設計はプロジェクトの保守性を決める重要な要素というのもあり、考えるのに工数がかかります。
今回作成したプロジェクトを基本形としながら、規模やシステムの特性に応じた構造に調整していければよいと考えています。
非常に長文記事になってしまいましたが、ここまで読んでくださった方にとって少しでも参考になれば嬉しいです。