はじめまして。オプティムのR&Dに所属している新卒2年目の板垣です。
普段は、AI の学習に必要な教師データを作成するためのアノテーションツールの設計・開発・運用を行なっています。
アノテーションツール自体は Web アプリで、クライアント側は React と TypeScript、サーバー側は Go で実装しています。
さて、先日 Clean Architecture 達人に学ぶソフトウェアの構造と設計 という本が ITエンジニアに読んでほしい!技術書・ビジネス書 大賞2019 の技術書部門ベスト10にノミネートされました。
本記事を読もうと思って下さった方の中には、この本を読もうと思っている、または、もう読んだという方が結構いらっしゃるのではないでしょうか。
かくいう私も、携わっているソフトウェアのソースコードがひどくて(というより、そのアーキテクチャでは耐えられなくなってきたと言った方が正しいかもしれません)、追加機能の開発がしにくい、テストしにくい、特定のフレームワーク・デバイス・ライブラリ等への依存度が強くて変更しにくいといった問題を抱えていたというのもあり、助けを求めてこの本を読んだ人の一人です。
そこで今回は、API サーバーを Clean Architecture で構築してみて、そのメリットを感じてみたいと思います。
(なお、今回紹介することは Clean Architecture 達人に学ぶソフトウェアの構造と設計 の中に書いてあることのほんの一部であり、他にも本書にはアーキテクチャに関する様々な情報・知見が分かりやすくまとめられているので、アーキテクチャに興味のある方はぜひ一度ご覧になってみてください。)
Clean Architecture (クリーンアーキテクチャ) とは
Clean Architecture とは一言で簡単に言うと、ソフトウェアをレイヤーに分けることによって関心の分離を達成するためのアーキテクチャパターンです(原典)。
アプリケーション開発でよくある問題として、アプリケーションが特定のフレームワークやドライバー等に強く依存してしまっていて、特定のフレームワークやドライバー等に変更が入った場合やそれらを取り替えようとした場合に、アプリケーション自体に大きな改修が必要であったり、そもそも改修が困難であったりする問題があります。
Clean Architecture では、アプリケーションの内側のレイヤーが外側のレイヤーに依存するのではなく、外側のレイヤーが内側のレイヤーに依存するアーキテクチャであるので、そのような問題が起きづらくなります。
4つのレイヤー
(図の使用に関しては、著者である Uncle Bob こと Robert C. Martin に許可を頂きました。出典元は原典です。
Thank you for your kind cooperation, Mr. Martin!)
上図にあるように、Clean Architecture は基本的に以下の4つのレイヤーからなります。
Entities
企業全体の最重要ビジネスルールをカプセル化したものです。メソッドを持ったオブジェクトでも、データ構造や関数でも構いません。
Use Cases
「アプリケーション固有」のビジネスルールが含まれています。エンティティとのデータの流れを組み立てます。
Interface Adapters
外部から、ユースケースとエンティティーで使われる内部形式にデータを変換、または内部から外部の機能にもっとも便利な形式に、データを変換する Adapter です。
Infrastructures [Frameworks & Drivers]
クライアントからの入力を受け付けたり、クライアントへの出力を行ったり、データベースのような外部機能に接続したりと、外界とアプリケーションの内側をつなぐ役割を担います。
特定のフレームワークやドライバー等に依存するので、このレイヤーに関する知識は Interface Adapters より内側に入らないよう注意します。
4つのレイヤーと書きましたが、Clean Architecture とはあくまでもアプリケーション固有のデータ構造やビジネスルールが、外側の特定のフレームワークやドライバー等に依存しないために、「アプリケーションの内側に外側を依存させようという概念」のことであり、具体的なレイヤー(ディレクトリ階層)を決めるものではありません。
したがって、場合によっては下記の Interface Adapters と Infrastructures [Frameworks & Drivers] をまとめたり、Web アプリのクライアント側などは独特な構造にしたりする場合があります(今回はそれについては考えません)。
上図の右下について
図だけでは矢印が何を表しているか分からないと思うので、簡単に説明します。
- 「Use Case Interactor」は「Use Case Input Port」Interface を継承(=実装)し、「Use Case Output Port」Interface に関連します(=使用します)
- Controllerは「Use Case Input Port」Interface に関連します(=使用します)
- Presenterは「Use Case Output Port」Interface を継承(=実装)します
ここで、Use Case Input Port、Use Case Output Port の 「Port」 とは何かを考えます。
おそらく、Use Cases とその外側 (Infrastructure) との出入り口という意味で Port を使っているのだと推測します。
そこで、以降ではこの Port という言葉を積極的に使っていこうと思います。
次に、Use Case Interactor の 「Interactor」とは何かを考えます。
おそらく、クライアントから入力を受け取って出力を返すまでの過程で、他の Port を経由して各 Infrastructure とやりとりをするので、それで Interactor という名前がついているのだと推測します。
これまでを踏まえて、データの流れをまとめると、
- クライアントからのリクエストを Controller へ
- Controller からInput Port を通って、Use Case Interactor へ
- Use Case Interactor 上で専用の各 Port を通って データの保存先や外部サービス等とのデータのやりとりを行い、外側に返すデータを生成
- データのやりとりで生じるデータの具体的な変換処理は Port の Interface を継承した Gateway が行います
- Use Case Interactor から Output Port を通って、クライアントへ
- Output Port の具体的な処理はOutput Port の Interface を継承した Presenter が行います
DIP
また、アプリケーションの内側(のレイヤー)に外側(のレイヤー)を依存させるため、 「DIP(依存関係逆転の原則)」に従って、実装していきます。
DIP とは Clean Architecture の実装の観点でわかりやすく言えば、あるレイヤーが自分より内側のレイヤーもしくは同じレイヤーの Interface を実装したり使用したりしていいが、その逆、つまり自分より外側のレイヤーの Interface を実装したり使用してはいけないということで、これさえ守れば Clean Architecture が実現できます。
Clean Architecture のメリット・デメリット
メリット
上記でも少し書きましたが、Clean Architecture を採用することで以下のメリットが期待できます。
- フレームワーク独立
- アーキテクチャは、ソフトウェアのライブラリに依存しません。フレームワークを道具として使うことを可能にし、システムはフレームワークの限定された制約に縛られません。
- テスト可能
- ビジネスルールは、UI、データベース、ウェブサーバー、その他外部の要素なしにテストできます。
- UI 非依存
- ビジネスルールの変更なしに、Web UI から コンソール UI への変更といった UI の置き換えができます。
- DB 非依存
- DB を他のものと交換することができます。ビジネスルールは、特定のDBどころか、DBにすら拘束されないかもしれません。
- 外部エージェント非依存
- ビジネスルールは、外界の Interface について何も知りません。
つまり、
「特定のフレームワークやドライバー等に依存しない、テストが容易なアプリケーション」が作れるようになります。
特定のフレームワークやドライバー等依存しないので、それらの切り替えも容易となります。
もちろん、Clean Architecture は銀の弾丸ではありません。以下のようなデメリットも存在します。
デメリット
- 大量のファイル(文字数)
- 単純にファイル数(文字数)が多くなります。理由としては依存関係の逆転(内部に外部を依存させること)のために Interface が必要になったり、各層に対する適切なデータ変換のための Adapter が必要になったりなど、画面->DB->画面と一往復させるだけでもいろいろな Interface が必要になってしまうためです。
- 工数(リソース・コスト)の増大
- 上記の点が原因。ゆえにプロトタイプの作成や、新規開発で人的リソースが足りていない状態で納期が短い場合にはあまり適していないと思います。
実装例
今回は、上記の4つのレイヤーで構築しやすい API サーバー を例にして、Clean Architecture を実現していきます。
なお、変数・構造体・Interface 名や定義、ディレクトリの階層、エラー処理等の問題は置いといて、Clean Architecture に着目して、ざっと簡単に素直に実装していきます。
ディレクトリの階層は以下のようにします。
app/ ├── main.go ├── config/ │ ├── config.go (割愛) │ └── config.toml (割愛) ├── domain/ │ └── entity/ │ └── datasource.go ├── infrastructure/ │ ├── databese/ │ │ └── mysql/ │ │ └── mysql.go │ └── waf/ │ └── echo/ │ ├── datasource.go │ ├── request/ │ │ └── datasource.go │ ├── response/ │ │ ├── datasource.go │ │ └── error.go (割愛: 同パッケージの datasource.go と同じ役割です) │ ├── server.go │ └── validator.go (割愛) ├── interface/ │ ├── controller/ │ │ └── datasource.go │ ├── errs/ │ │ └── http/ (割愛: usecase/port/error.go の Interface の実装をここで行います) │ │ ├── code.go │ │ └── error.go │ │ │ ├── gateway/ │ │ ├── database/ │ │ │ ├── datasource.go │ │ │ └── rdb/ │ │ │ ├── datasource.go │ │ │ └── handler.go │ └── presenter/ │ ├── datasource.go │ └── http.go └── usecase/ ├── interactor/ │ ├── container.go │ └── datasource.go └── port/ ├── error.go ├── repository/ │ └── datasource.go └── server/ └── datasource.go
Entities
アプリケーション内のデータ構造が定義してあります。
domain/entity/datasource.go
package entity type DataSourceID string type DataSource struct { ID DataSourceID Type string }
Use Cases
アプリケーション固有のビジネスルールが含まれています。
Port
外部(Infrastructures)と内部(Entities)とのデータの流れ(入出力)をここで「抽象的」に組み立てます。
ここでは、外部の情報を知らないということが重要です。
つまり、Use Cases の内側では、そのアプリがクライアントに対して JSON や HTML を返す API サーバーとして振る舞うことや、
データの保存先(Repository)が DB なのかファイルなのかといったことを知らなくてもいいということです。
また、今回の実装にはないですが、他に考えられることとしては、言語の翻訳機能(Translater)がついていれば、それが内部システムなのか外部サービスなのか、
データの解析機能がついていれば、解析するもの(Analyzer)が内部システムなのか外部サービスなのかは知らなくてもいいということです。
ビジネスルールとしては必要なので、Use Cases として抽象的に定義して具体的な実装はその外側でやります。
- データの保存先 (Mysql etc...) とのやりとりの抽象化
usecase/port/repository/datasource.go
package repository import ( ".../sample-app/domain/entity" ".../sample-app/usecase/port" ) type DataSourceRepository interface { FindAll() ([]entity.DataSource, port.Error) Find(entity.DataSourceID) (*entity.DataSource, port.Error) }
- クライアントとのやりとりの抽象化
usecase/port/server/datasource.go
package server import ( ".../sample-app/domain/entity" ".../sample-app/usecase/port" ) /* * Input Port * └─ Interactor で実装、Controller で使用される */ type DataSourceInputPort interface { DownloadDataSources() (*DownloadDataSourcesResponse, port.Error) DownloadDataSource(*DownloadDataSourceRequestParams) (*DownloadDataSourceResponse, port.Error) } type DownloadDataSourceRequestParams struct { DataSourceID entity.DataSourceID } /* * Output Port * └─ Presenter で実装、Interactor で使用される */ type DataSourceOutputPort interface { DownloadDataSources([]entity.DataSource) (*DownloadDataSourcesResponse, port.Error) DownloadDataSource(*entity.DataSource) (*DownloadDataSourceResponse, port.Error) } type DownloadDataSourceResponse struct { DataSource *entity.DataSource } type DownloadDataSourcesResponse struct { DataSources []entity.DataSource }
- エラーの抽象化
今回のアプリケーションのエラーは Port を経由した外部とのやりとりで生じると思うので、ここに定義します。 Entities に関数がある場合は、そちらに定義した方がいいかもしれません。
usecase/port/error.go
package port type Error interface { Error() string StatusCode() int }
Interactor
上記の Input Port (入力) と Output Port (出力) を結びつけます。
その過程で他の Port を経由して各 Infrastructure とのやりとりも行います。
usecase/interactor/datasource.go
package interactor import ( ".../sample-app/config" ".../sample-app/usecase/port" ".../sample-app/usecase/port/server" ".../sample-app/usecase/port/repository" ) type DataSourceInteractor struct { Config *config.Config OutputPort server.DataSourceOutputPort DataSourceRepository repository.DataSourceRepository } func NewDataSourceInteractor( config *config.Config, outputPort server.DataSourceOutputPort, dataSourceRepository repository.DataSourceRepository, ) *DataSourceInteractor { return &DataSourceInteractor{ Container: newUsecaseContainer(config), OutputPort: outputPort, DataSourceRepository: dataSourceRepository, } } // Input Port の実装 func (i *DataSourceInteractor) DownloadDataSources() (*server.DownloadDataSourcesResponse, port.Error) { res, err := i.DataSourceRepository.FindAll() if err != nil { return nil, err } // Output Port の使用 return i.OutputPort.DownloadDataSources(res) } func (i *DataSourceInteractor) DownloadDataSource(params *server.DownloadDataSourceRequestParams) (*server.DownloadDataSourceResponse, port.Error) { res, err := i.DataSourceRepository.Find(params.DataSourceID) if err != nil { return nil, err } return i.OutputPort.DownloadDataSource(res) }
Interface Adapters
外部から内部(Entities、Use Cases)で使われる形式にデータ変換したり、内部から外部の機能にもっとも便利な形式にデータを変換したりする Adapter です。
Controller
外部(クライアント) → 内部
interface/controller/datasource.go
package controller import ( ".../sample_app/config" ".../sample_app/usecase/interactor" "...sample_app/usecase/port" "...sample_app/usecase/port/server" ".../sample_app/interface/presenter" ".../sample_app/interface/gateway/database/rdb" ) type DataSourceController struct { InputPort port.DataSourceInputPort } func NewDataSourceController(config *config.Config) *DataSourceController { return &DataSourceController{ InputPort: interactor.NewDataSourceInteractor( config, presenter.NewHTTPPresenter(config), database.NewDataSourceRDBRepository(config), ), } } func (c *DataSourceController) DownloadDataSources() (*server.DownloadDataSourcesResponse, port.Error) { // Input Port の使用 return c.InputPort.DownloadDataSources() } func (c *DataSourceController) DownloadDataSource(params *server.DownloadDataSourceRequestParams) (*server.DownloadDataSourceResponse, port.Error) { return c.InputPort.DownloadDataSource(params) }
Presenter
内部 → 外部(クライアント)
interface/presenter/http.go
package presenter import ( ".../sample-app/config" ".../sample-app/usecase/port/server" ) type HTTPPresenter struct { config *config.Config server.DataSourceOutputPort } func NewHTTPPresenter(config *config.Config) *HTTPPresenter { return &HTTPPresenter{ config: config, } }
interface/presenter/datasource.go
package presenter import ( ".../sample_app/domain/entity" ".../sample_app/usecase/port" ".../sample_app/usecase/port/server" ) // Output Port の実装 func (p *HTTPPresenter) DownloadDataSources(datasources []entity.DataSource) (*server.DownloadDataSourcesResponse, port.Error) { res := &server.DownloadDataSourcesResponse{} res.DataSources = datasources return res, nil } func (p *HTTPPresenter) DownloadDataSource(datasource *entity.DataSource) (*server.DownloadDataSourceResponse, port.Error) { res := &server.DownloadDataSourceResponse{} res.DataSource = datasource return res, nil }
Gateway
内部 ←→ 外部(DB、FileStorage 等の Device)
interface/gateway/database/rdb/datasource.go
package rdb import ( ".../sample_app/config" ".../sample_app/domain/entity" ".../sample_app/usecase/port" errs ".../sample_app/interface/errs/http" ) type DataSourceRDBRepositoryAdapter struct { config config.Config SqlHandler } func NewDataSourceRDBRepository( config config.Config, ) *DataSourceRDBRepositoryAdapter { return &DataSourceRDBRepositoryAdapter{ config: config, } } func (repo *DataSourceRDBRepositoryAdapter) FindAll() ([]entity.DataSource, port.Error) { var datasources []entity.DataSource row, err := repo.Query("SELECT id, type FROM datasource_m") defer row.Close() if err != nil{ return nil, errs.ErrorUnknown{} } var ( id string dataType string ) for row.Next() { if err = row.Scan(&id, &dataType); err != nil { return nil, errs.ErrorUnknown{} } datasources = append(datasources, entity.DataSource{ ID: entity.DataSourceID(id), Type: dataType, }) } return datasources, nil } func (repo *DataSourceRDBRepositoryAdapter) Find(identifier entity.DataSourceID) (*entity.DataSource, port.Error) { // 例えば、同じ RDB でも MySQL と PostgreSQL でクエリを変更するとかであれば、ファイルを分ける必要が出てくるかもしれない row, err := repo.Query("SELECT id, type FROM datasource_m WHERE id = ?", identifier) defer row.Close() if err != nil{ return nil, errs.ErrorUnknown{} } if (!row.Next()) { return nil, errs.ErrorResourceNotFound{} } var ( id string dataType string ) if err = row.Scan(&id, &dataType); err != nil { return nil, errs.ErrorUnknown{} } datasource := entity.DataSource{ ID: entity.DataSourceID(id), Type: dataType, } return &datasource, nil }
interface/gateway/database/rdb/handler.go
package rdb import ( ".../sample_app/usecase/port" ) type SqlHandler interface { Execute(string, ...interface{}) (Result, port.Error) Query(string, ...interface{}) (Row, port.Error) } type Result interface { LastInsertId() (int64, port.Error) RowsAffected() (int64, port.Error) } type Row interface { Scan(...interface{}) port.Error Next() bool Close() port.Error }
Infrastructures [Frameworks & Drivers]
クライアントからの入力を受け付けたり、クライアントへの出力を行ったり、データベースのような外部機能に接続したりと、外界とアプリケーションをつなぐ役割を担います。
依存している特定のフレームワークやドライバー等に関する知識は Interface Adapters より内側に入らないよう注意します。
ただし、例えば何らかの RDB (MySQL etc...) を使用する場合は、Interface Adapters は RDB に関する知識等は知った上で Interface を実装したり、具体的な処理を定義したりします。
外側の Infrastructures [Frameworks & Drivers] はDIP(依存関係逆転の原則)に基づき、内部の具体的な処理(関数)を使用したり、Interface を実装したりします。
MySQL (RDB)
infrastructure/database/mysql/mysql.go
package mysql import ( "database/sql" _ "github.com/go-sql-driver/mysql" ".../sample_app/config" ".../sample_app/usecase/port" errs ".../sample_app/interface/errs/http" ".../sample_app/interface/gateway/database/rdb" ) type SqlHandler struct { Conn *sql.DB } // DIP に基づき、rdb.SqlHandler の Interface を実装していく func NewSqlHandler(config config.DB) (rdb.SqlHandler, port.Error) { var ( host = config.Host port = config.Port dbName = config.Database user = config.User password = config.Password ) driverName := "mysql" dataSourceName := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s", user, password, host, port, dbName) conn, err := sql.Open(driverName, dataSourceName) if err != nil { return nil, errs.ErrorUnknown{} } err = conn.Ping() if err != nil { return nil, errs.ErrorUnknown{} } sqlHandler := new(SqlHandler) sqlHandler.Conn = conn return sqlHandler, nil } func (handler *SqlHandler) Execute(statement string, args ...interface{}) (rdb.Result, port.Error) { res := SqlResult{} result, err := handler.Conn.Exec(statement, args...) if err != nil { return res, errs.ErrorUnknown{} } res.Result = result return res, nil } func (handler *SqlHandler) Query(statement string, args ...interface{}) (rdb.Row, port.Error) { rows, err := handler.Conn.Query(statement, args...) if err != nil { return new(SqlRow), errs.ErrorUnknown{} } row := new(SqlRow) row.Rows = rows return row, nil } type SqlResult struct { Result sql.Result } func (r SqlResult) LastInsertId() (int64, port.Error) { res, err := r.Result.LastInsertId() if err != nil { return res, errs.ErrorUnknown{} } return res, nil } func (r SqlResult) RowsAffected() (int64, port.Error) { res, err := r.Result.RowsAffected() if err != nil { return res, errs.ErrorUnknown{} } return res, nil } type SqlRow struct { Rows *sql.Rows } func (r SqlRow) Scan(dest ...interface{}) port.Error { if err := r.Rows.Scan(dest...); err != nil { return errs.ErrorUnknown{} } return nil } func (r SqlRow) Next() bool { return r.Rows.Next() } func (r SqlRow) Close() port.Error { if err := r.Rows.Close(); err != nil { return errs.ErrorUnknown{} } return nil }
Echo (WAF: Web Application Framework)
Controller にクライアントからのリクエストを内部に流して、 Presenter からのレスポンスをクライアントに返すものです。
今回は Presenter からのレスポンス をもとに JSON を生成してクライアントに返しています。
やるかどうかは別として、ここ(Infrastructure)を変更すれば Presenter からのレスポンスを使って HTML や CSV 等を生成してクライアントに返したり、
そもそも API サーバーではなく、CLI にしたりもできると思います。
infrastructure/waf/echo/request/datasource.go
package request type DataSourceRequestParams struct { Name string `path:"name" v-get:"required"` }
infrastructure/waf/echo/response/datasource.go
package response import ( "net/http" ".../sample_app/domain/entity" ".../sample_app/usecase/port/server" ) type DataSource struct { Name entity.DataSourceID `json:"name"` Type string `json:"type"` } type DataSourceResult struct { DataSource *DataSource `json:"datasource"` } type DataSourcesResult struct { DataSources []DataSource `json:"datasources"` } func DataSourceResponseAdapter(_res *server.DownloadDataSourceResponse) (DataSourceResult, int) { var res DataSourceResult if _res.DataSource == nil { res = DataSourceResult{ DataSource: nil, } } else { res = DataSourceResult{ DataSource: &DataSource{ Name: _res.DataSource.ID, Type: _res.DataSource.Type, }, } } return res, http.StatusOK } // この Adapter がないと JSON を返すという前提が Use Cases の内側に入ってきてしまうので、ここであえてマッピングしてます func DataSourcesResponseAdapter(_res *server.DownloadDataSourcesResponse) (DataSourcesResult, int) { res := DataSourcesResult{} res.DataSources = make([]DataSource, 0) for i := 0; i < len(_res.DataSources); i++ { datasource := &_res.DataSources[i] res.DataSources = append(res.DataSources, DataSource{ Name: datasource.ID, Type: datasource.Type, }) } return res, http.StatusOK }
infrastructure/waf/echo/datasource.go
package echo import ( "net/http" "github.com/labstack/echo" ".../sample_app/domain/entity" ".../sample_app/usecase/port" ".../sample_app/interface/controller" ".../sample_app/infrastructure/waf/echo/request" ".../sample_app/infrastructure/waf/echo/response" ) func (s *Server) GetDataSources(controller *controller.DataSourceController) echo.HandlerFunc { return C(func(c *Context) error { _res, err := controller.DownloadDataSources() if err != nil { c.Logger().Error(err) return echo.NewHTTPError(http.StatusBadRequest) } res, status := response.DataSourcesResponseAdapter(_res) return c.JSON(status, res) }) } func (s *Server) GetDataSource(controller *controller.DataSourceController) echo.HandlerFunc { return C(func(c *Context) error { req := new(request.DataSourceRequestParams) if err := c.BindValidate(req); err != nil { c.Logger().Error(err) return echo.NewHTTPError(http.StatusBadRequest) } // クライアントからのリクエストを内部に流して、 // 内部からのレスポンスをクライアントに流す。 _req := port.DownloadDataSourceRequestParams { DataSourceID: entity.DataSourceID(req.Name), } _res, err := controller.DownloadDataSource(&_req) if err != nil { c.Logger().Error(err) return echo.NewHTTPError(http.StatusBadRequest) } res, status := response.DataSourceResponseAdapter(_res) return c.JSON(status, res) }) }
infrastructure/waf/echo/error.go
package echo import ( "net/http" "github.com/labstack/echo" ".../sample_app/infrastructure/waf/echo/response" ) func CustomHTTPErrorHandler(err error, c echo.Context) { code := http.StatusInternalServerError message := "Internal Server Error" if e, ok := err.(*echo.HTTPError); ok { code = e.Code message = e.Message.(string) } body := response.Error{ StatusCode: code, Message: message, } c.JSON(code, body) }
infrastructure/waf/echo/server.go
package echo import ( "fmt" "github.com/labstack/echo" "github.com/labstack/echo/middleware" "github.com/labstack/gommon/log" conf ".../sample_app/config" ".../sample_app/interface/controller" ".../sample_app/infrastructure/database/mysql" ) type Server struct { *echo.Echo *conf.Config } func createServer(config *conf.Config) (*Server, error) { return &Server{ Echo: echo.New(), Config: config, }, nil } func (s *Server) setRouter() { s.Echo.Use( middleware.Logger(), middleware.Recover(), func(h echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { return h(&Context{c}) } }, ) s.Echo.Binder = &CustomBinder{} s.Echo.HTTPErrorHandler = CustomHTTPErrorHandler handler, err := mysql.NewSqlHandler(s.Config.DB) if err != nil { s.Echo.Logger.Fatal(err) } api := s.Echo.Group(s.getEndpointRoot()) { datasourceController := controller.NewDataSourceController(s.Config) // datasources api.GET("/datasources", s.GetDataSources(datasourceController)) api.GET("/datasources/:name", s.GetDataSource(datasourceController)) } } func (s *Server) getPrefix() string { return fmt.Sprintf("%s", s.Config.Meta.Version) } func (s *Server) getEndpointRoot() string { return fmt.Sprintf("/api/%s", s.getPrefix()) } func (s *Server) getAddr() string { return fmt.Sprintf("%s:%d", s.Config.Server.Host, s.Config.Server.Port) } func (s *Server) run() { s.Echo.Logger.Fatal(s.Echo.Start(s.getAddr())) } func Run() { config, err := conf.LoadConfig() if err != nil { panic(err) } s, err := createServer(&config) if err != nil { panic(err) } if config.Server.Debug { s.Echo.Logger.SetLevel(log.DEBUG) } s.setRouter() s.run() }
main.go
package main import ( server ".../sample_app/infrastructure/waf/echo" ) func main() { server.Run() }
まとめ
以上、「API サーバーを Clean Architecture で構築する」という題材で、Clean Architecture の軽い紹介と Clean Architecture を意識した API サーバーの実装例を書きました。
今回はテストに関して割愛しましたが、Clean Architecture で実装すると各レイヤー毎にテストが可能なので、何か問題が生じた時に、ビジネスルール(Entities、Use Cases)が原因なのか、それとも外界とのやりとり(Interface Adapters、Infrastructures [Frameworks & Drivers])の原因なのかの切り分けがしやすくなると思います。
また、「上記のような実装をしたとして、API の仕様が変わった時に結局 Entities まで修正しなくてはいけない時があるので大変だし、このような実装は無駄なのでは?」という疑問が生じるかもしれませんが、それは要求されているビジネスルールが変わったということなので当然のことではないかと思います。そのコスト面のデメリットよりも、はじめの方で挙げた Clean Architetecture のメリットである、フレームワーク独立・テスト可能・UI 非依存・DB 非依存・外部エージェント非依存というメリットを考えれば決して無駄ではありません。ただし、上記のようなデメリットも確かにあるので、メリット・デメリットのトレードオフを考慮し、各々が作ろうとしているものと現在置かれている状況を踏まえて、導入するか検討する必要があると思います。
最後に、
オプティムでは、こうしたモノリシックなアプリのアーキテクチャや、マイクロサービスの全体的なアーキテクチャの設計・開発をしてみたい、というエンジニアを募集しています。興味のある方は、こちらをご覧ください。