少しずつ始めるgoでのクリーンアーキテクチャ

みなさんこんにちは!株式会社BLAM CTOの大沼です。(ぬま (@_numa999) | Twitter
普段はテクノロジー本部で、自社サービス「カイコク」の開発やマネジメント業務を行なっています。

今回は直近チームで注力したクリーンアーキテクチャに関して書きます。

なぜクリーンアーキテクチャに注力したのか?

日頃カイコクの開発チームでは、新機能はもちろんですが、内部的な改善も常に気づいたらissueを上げ、順次改善に取り組む活動を行なっています。 そんな中で直近、

  • 一部DRYじゃなくなっているコードが発生し始めている
  • 一部モジュールが肥大化しているので、テストコードを書くのが大変(モックを作るのが大変)
  • そもそもテストカバレッジが落ちてきているし、テストの実行時間が伸びてきている

といった課題がありました。上記の課題はクリーンアーキテクチャですとは言っている物の、さまざまな事情がありリニューアル当時はレイヤーをとにかく少なくしようと言う方針で進めました。

なので、現状あるものをしっかり活かす方針で改めて必要なレイヤーの洗い出しとそもそもチームでレイヤーごとの責務が統一されていない問題を改めてチームで考え直し、上記の課題解決のためにクリーンアーキテクチャに注力しました。

note.com

どのようなアーキテクチャにしたか?

下記の画像が大枠の全体像になります。基本的には有名な図にできるだけ習うように命名をしています。

ディレクトリ構造は下記のように変更しています。

/
├ internal
│ ├ infrastructures
│ ├ adapters
│ │ ├ controllers
│ │ └ gateways
│ ├ usecases
│ └ entities
│   ├ handlers
│   ├ models
│   └ repositories
└ main.go

各レイヤーの責務は下記のようにしています。

entities

ここではビジネスロジックを表現する

  • model
  • repository
  • handler

のinterfaceとstructを定義しています。

user_model.go

// user_model.go
type User struct {
    ID        uint
    FirstName string
    LastName  string
}

user_repository.go

// user_repository.go
// 実際には引数は大きいので、構造体にしています
type UserRepository interface {
    Find(firstName string) ([]User, error)
    Create(firstName string, lastName string) (*User, error)
}

sql_handler.go

// sql_handler.go
// ORMとしてgormを使用しています
type SQLHandler = gorm.DB

usecase

entitiesのstructとinterfaceを使用し、ユースケースを実現します。

type UserUsecase interface {
    RegisterUser(input RegisterUserInput) (*RegisterUserOutput, error)
}

type RegisterUserInput struct {
    FirstName string
    LastName string
}

type RegisterUserOutput struct {
    UserName string
}

type userInteractor struct {
    userRepository UserRepository
}

func NewUserUsecase(userRepository UserRepository) UserUsecase {
    return &userInteractor{
        userRepository,
    }
}

func (i *userInteractor) RegisterUser(input RegisterUserInput) (*RegisterUserOutput, error) {
    u, err := i.userRepository.Create(input.FirstName, input.LastName)
    if err != nil {
        return nil, err
    }

    return &RegisterUserOutput{UserName: u.FirstName + u.LastName}, nil
}

controller

controllerはhttpリクエストを受け取るところで、リクエストの検証と、リクエストからusecaseへ渡す値を生成することを役割とします。

// user_controller.go
// webのフレームワークとしてechoを使用しています
type UserController interface {
    Get(ctx echo.Context) error
    Post(ctx echo.Context) error
}

type userController struct {
    userUsecase UserUsecase
}

func NewUserController(userUsecase UserUsecase) UserController {
    return &userController{
        userUsecase,
    }
}

func (c *userController) Get(ctx echo.Context) error {
    // ...
}

func (c *userController) Post(ctx echo.Context) error {
    // リクエストの検証とusecaseへ渡す引数の作成
    req := RegisterUserInput{FirstName: "test", LastName: "tarou"}
    res, err := c.userUsecase.RegisterUser(req)
    if err != nil {
        // 受け取ったエラーをハンドリング
        return ctx.String(http.StatusInternalServerError, "予期せぬエラーが起きました")
    }

    return ctx.String(http.StatusOK, res.UserName)
}

gateway

gatewayではhandlerとrepositoryを参照し、データベースの操作を行う実装を書きます。

// user_repository.go
type userRepository struct {
    sqlHandler SQLHandler
}

func NewUserRepository(sqlHandler SQLHandler) UserRepository {
    return &userRepository{
        sqlHandler,
    }
}

func (r *userRepository) Find(firstName string) ([]User, error) {
    user := []User{}
    if err := r.sqlHandler.Where("first_name = ?", firstName).Find(user).Error; err != nil {
        return nil, err
    }

    return user, nil
}

func (r *userRepository) Create(firstName string, lastName string) (*User, error) {
    // ユーザー作成処理
    return nil, nil
}

次に

ここまでが各レイヤーの超簡易的なサンプルで、上記のコードを組み立て、infrastructure層で接続情報等を作成し、main.goでDIを行います。

まとめ

ここまでで、カイコクでのアーキテクチャのイメージをご紹介できたかなと思います!各レイヤーの詳細な考え方等はたくさん記事があるので、「カイコクではこのようにしています!」の紹介でした。

直近では、gomockとgo-sqlmockを活用し、単体テストがかなり書きやすくなりテストカバレッジの向上にもしっかりと取り組め、直近の課題は解決の方向に向かっていると思います。

github.com

github.com

ただやってみてまだまだ、解決したい点や悩んだ末にやらなかった点があります。

  • DIコンテナを作成したい(wireとか)
  • ORMの型の取り扱い?(entitiesに外部依存の型が入るのって良いんだっけ?)
  • presenterは結局usecaseの値を加工するだけになってしまうのではないかと思いつくらなかったが良いのか?
    • 結局echoでrender関数を使用するときにオブジェクトを渡すことになる
  • CQRSやDTOなどの考慮はしていない
  • DBの更新でトランザクションが絡む処理の最適解がまだ見えていない

等、今のところ困ってはいませんが、将来的に考えないといけないなと言う点はまだまだたくさんあります。

大枠のところしか記載できていないですが、これからgoでクリーンアーキテクチャをはじめるチームに少しでも参考になると嬉しいです!

参考

blog.cleancoder.com

nrslib.com

tech.mirrativ.stream

最後に

BLAMではエンジニアを募集しています。ご興味のあるかた、ラフに話を聞いてみたいと言う方がいましたらぜひこちらのフォームからご応募ください!

recruit.blam.co.jp

www.wantedly.com