ぐーたらきつねの趣味のブログ

ぐーすかきつねの趣味ブログ

ミクさんを中心とするボカロ関連のこと・お菓子作り・大学生活のことなどを書いていきます。

Goでクリーンアーキテクチャ的な

初めに

こちらは,42Tokyoというプログラミングスクールの2022年度のアドベントカレンダーの1日目の記事になります.

qiita.com

自分は2020年11月ごろから42Tokyoに在籍しているぐーすかきつねという者です.
この記事では,アドベントカレンダー1日目でもあるので,簡単に42tokyoの紹介をしたのち,最近自分が学んだオニオンアーキテクチャ的なアーキテクチャを用いたGoでのAPIサーバの実装に関する話でもしようかと思います.

42Tokyoとは

簡単に42Tokyoの紹介を行っていこうと思います. 42Tokyoとは42というフランス発のエンジニア養成機関の東京校です.(公式サイトはこちら

42tokyo.jp

2021年や2020年のアドベントカレンダーを書く際には,「謎の」プログラミングスクールなどと,枕詞をつけていましたが,少しは巷にも噂が広がってきているのでしょうか...
42には大きな特徴として

  1. 教員がいない
  2. 課題解決型
  3. 完全無料

という特徴があり,主にCやC++を用いた,低レイヤな開発を通して,エンジニアとして必要とされる常識を身につけていくためのカリキュラムが組まれています.
卒業という概念は正確には無いのですが,最近42tokyoにおいて卒業的な状態になった方の記事が,中で学べることをいい感じにまとめてくださっているので,より詳しくカリキュラムなどを知りたい方はコチラを見てみてください(ちなみに明日のアドベントカレンダーを担当してくださっています).

note.com

Goでオニオンアーキテクチャ

さて,ここからが本題です.
42の課題の中では,メインストリーム的な課題以外にも色々脇道にそれて学びを深められる課題があり,その中にGolangを使ってオニオンアーキテクチャTwitterのバックエンドAPIを作るという課題があります.
ここから書いていくのは,課題で学んだことをもとにして,Goでオニオンアーキテクチャ的な実装をするならこういう感じでパッケージを切ると綺麗かなぁと考えてみたものになります.
あくまでも,オニオンアーキテクチャ”的な”なので,完璧に沿っていない部分はたくさんあると思います.気になる部分に関してはご指摘お願いします.

そもそもオニオンアーキテクチャとは

Jeffrey Palermoさんによってこちらの記事で提唱されたのが始まりだと言われています.

The Onion Architecture : part 1 | Programming with Palermo

それまでに提唱されていた,レイヤードアーキテクチャに対して,依存性逆転(DI)をすることで,Domain層がコアとなり,ビジネスロジックを中心とした実装を可能にしています.

プロジェクトのパッケージ構成

以下のようにパッケージを切ってみました.

├── presentation
│      │
│      ├── rest
│      │     └── account.go
│      │     └── <other services>.go
│      │     └── router.go
│      │
│      └── middleware
│            └── auth.go
│            └── error.go
├── usecase
│      └── account.go
│      └── <other services>.go
│
├── domain
│      │
│      ├── entity
│      │     └── account.go
│      │     └── <other services>.go
│      │
│      ├── repository
│      │     └── account.go
│      │     └── <other services>.go
│      │
│      └── value
│            └── account.go
│            └── <other services>.go
│
├── infra
│     └── account.go
│     └── <other services>.go
│
├── shared
│      └── useful_function.go
│
└── main.go

オニオンアーキテクチャにおけるレイヤーとディレクトリの対応は以下のようになっています.

以下では,RESTのCRUD操作が可能なAPIサーバを実装することを考え,その中でもuser登録を行うエンドポイントを作るというシナリオで,各層にもたせる責務や実装のイメージを書いていきたいと思います.

プレゼンテーション層

プレゼンテーション層の責務は,APIのエンドポイントとなり,APIのタイプ(REST,gRPC,GUIJSON-RPC...)の違いを巻き取ったり,Goの場合は,Gin,Echo,muxなどのライブラリの違いなどを巻き取ったりして,ユースケース層以下の層がこれらに依存しないようにすることだと思っています.
自分は実装において以下のようなことを主軸として実装しています.

  1. APIのエンドポイントとして,リクエストを受け取る.
  2. リクエストに対し,必要に応じてvalidationを行う.
  3. ユースケース層で実装された,usecase関数に,APIのリクエストを加工したものを渡す.
  4. ユースケース関数からの返り値をレスポンスに加工して返す.

presentation/rest/account.goの実装のイメージは以下のような形になります.(以下の例はginをフレームワークとした実装です)

func (a *Account) accout_create(c *gin.Context) {
    req := new(req)

    // リクエストを受け取る(jsonapiの形で受け取っています)
    if err := jsonapi.UnmarshalPayload(c.Request.Body, req); err != nil {
        c.Error(err).SetType(http.StatusInternalServerError).SetMeta("account create UnmarshalPayload")
        return
    }
    // 手に入れたリクエストの一部を欲しい型などに加工する
    id, err := strconv.ParseUint(req.ID, 10, 64)
    if err != nil {
        c.JSON(http.StatusInternalServerError, err)
        return
    }

    // ユースケース層を呼び出し,create関数を実行
    accountUseCase := usecase.NewAccount(a.app)
    account, err := accountUseCase.Create(c, id, req.Name, req.Email)
    if err != nil {
        return
    }

    // ユースケース層の返り値をAPのレスポンスとして加工する(jsonapiの形で返す実装にしています)
    payload, err := jsonapi.Marshal(newResponse(*account))
    if err != nil {
        c.Error(err).SetType(http.StatusInternalServerError).SetMeta("account create Marshal")
        return
    }
    c.JSON(http.StatusOK, payload)
}

ユースケース

ユースケース層の責務は,ドメインオブジェクトの生成,使用,永続化依頼であったり,プレゼンテーション層へ返す値の生成を行うことだと思っています.
ユースケース層で行うことをプレゼンテーション層の中で一気に行うような実装を昔していたことがあるのですが,そうなると,プレゼンテーション層の実装が肥大化してしまったり,APIフレームワークの変更に対してのリファクタリングが少しつらく感じたりしました.
ユースケース層を生成することで,APIの提供方法や使用するフレームワークに引っ張られることなく,実装したい操作を定義することができるようになると感じています.
自分は実装において以下のようなことを主軸として実装しています.

  1. ドメインオブジェクトとして、entityを生成する
  2. (必要に応じて)domainルールを実行する
  3. (必要に応じて)entityの永続化処理を行う

usecase/account.goの実装のイメージは以下のような形になります.

func (a *accountUseCase) Create(c *gin.Context, id uint64, name string, email string) (*entity.Account, error) {
    //domainオブジェクトの生成
    account, err := entity.NewAccount(id, name, email)
    if err != nil {
        return nil, err
    }
    //(必要に応じて)domainルールを実行する
    //domainオブジェクトの永続化を行う
    out, err = a.app.Dao.Account().Insert(c, account)
    if err != nil {
        return nil, err)
    }
    return out, nil
}

ドメイン

ドメイン層ではリポジトリのインターフェースを定義することでDIを可能にしたり,ドメインオブジェクトとして値オブジェクトやエンティテを定義したり,必要に応じてドメインサービスを実装したりします.
自分は実装において以下のようなことを主軸として実装しています.

  1. エンティティを"適切"に定義する(これが難しい....)
  2. repositoryをinterfaceとして実装する.
  3. 適切に値オブジェクトを作って,エンティティの定義をより適切なものにする.
  4. Update処理に関わってくるドメインルールを適切に実装する.

domain/entity/account.goの実装のイメージは以下のようになります.

type Account struct {
    Id       uint64
    Name     string
    Email    string
    UserType uint64
}

func NewAccount(id uint64, name string, email string) (*Account, error) {
    //domainが適切か確認して,domainルールに版する場合にはエラーを返す
    //nameが適切か確認して,domainルールに版する場合にはエラーを返す

    //正しい入力がされている場合にはentityを生成して返す
    entity := &Account{
        Id:       id,
        Name:     name,
        Email:    email,
        UserType: account_type.TmpUser,
    }
    return entity, nil
}

domain/repository/account.goの実装のイメージは以下のようになります.
この定義だと場合によっては,entityにDatabaseの実装が入ってきてしまったりするので,必要に応じて,DTO(Data Transfer Object)を間に挟む実装を考えることもありますが,正直めんどくささが勝つので,そこまでやらないことが多いです.

type Account interface {
    Insert(ctx context.Context, a *entity.Account) (*entity.Account, error)
}

valueオブジェクトの実装に関しては,今回は割愛しますが,例えば,emailの定義をより詳細に行ったりする感じです.
上の実装では,emailをただのstringとして定義していますが,emailを/^[a-zA-Z0-9_.+-]+@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/;正規表現を満たすものとして定義するようなvalueオブジェクトを作り,それをentityのメンバーとして使用するなどが考えられます.

インフラストラクチャ層

ドメイン層においてインターフェスとして宣言したレポトリを実装します.
ドメインオブジェクトを永続化したり,永続化されたドメインオブジェクトを検索したりすることが責務となるように自分は実装しています.
オニオンアーキテクチャにおいてDI(依存性の逆転 Dependency Inversion)をすることの1番の嬉しさは,ソフトウェアを実装する際に中心とすべきドメイン(『プログラムを適用する対象となる領域』と捉えています)を,コードの依存性の中心におくことが出来ることだと思っています.
DIをすることで,ドメインと本来無関係であるインフラの要件(データベースに何を使うかとか,はたまたデータベースではなくメモリにキャッシュしとくとか)をドメインの実装に含まなくてすむことに,嬉しさがあると思っています.

インフラストラクチャ層を実装する際に自分が意識していることは特になく,その時の要件に応じたentityの永続化処理の実装をするようにしています.
infra/account.goの実装のイメージは以下のような形になります.

//dbがgormの場合
type accountRepository struct {
        db *gorm.DB
        sync.RWMutex
}

func (a *accountRepository) Insert(ctx context.Context, item *entity.Account) (*entity.Account, error) {
    a.RLock()
    defer a.Unlock()
    if err := a.db.Create(item).Error; err != nil {
        return nil, err
    }
    return item, nil
}

//dbがin memoryのmapの場合
type accountRepository struct {
    map[entity.Account.Id]*entity.Account
    sync.RWMutex
}

func (a *accountRepository) Insert(ctx context.Context, item *entity.Account) (*entity.Account, error) {
    a.RLock()
    defer a.Unlock()
    a.db[item.ID]=item
    return item, nil
}

まとめ

今回は,簡単にではありますが,オニオンアーキテクチャ的なアーキテクチャをGoを用いて書いてみる際の大まかな指針について書いてみました.
こちらの記事では相当説明を端折っていますが,アーキテクチャにおいてはとにかく言葉の定義が難しい(というか聞きなれない言葉がたくさん出てくる)ので,そこらへんも機会があれば自分の理解をまとめてみたいですね..(でもそういう記事は刺されるのが怖い...)
あと,今回コードはあくまで実装イメージなのですが,そのうちちゃんとしたコードをGitHubに載せたりしたい...
明日12月2日は最初に記事を紹介させていただいたkotaさんが「車輪の再発明は楽しい~Nand to Tetris編~」という題名で記事を書いてくださるようなので,そちらも是非読んで見てください.
それでは.