🙄

クリーンアーキテクチャを Go 言語で理解する

2022/12/29に公開

法政大学情報科学部 3 年のあべてつです。
GoCon mini in sendai でクリーンアーキテクチャの登壇をしました。
(オフライン開催で、温かい雰囲気の中自由に発表させて貰いました。ありがとうございます!!)
今回は、5 分間で話しきれなかった詳細な話をしたいと思います。

クリーンアーキテクチャとは?

クリーンアーキテクチャとは、設計方法です。
調べたら以下のような絵が出てくると思います。
この絵や記事から理解するのは大変だと思うので、実際に実装してみるのが一番早いと思います。

なので、図と具体的なコードを使ってわかりやすく説明しようと思います。
丸いと流れが分かりにくいので、縦に並び替えてみました。
(実際は adapter 層と usecase 層の間で interface を用いて依存関係が逆転していますが、今回は単純な矢印で表現しています。)

driver 層が controller 層の関数を呼ぶ
controller 層が usecase 層の input port の関数を呼ぶ
input port では、以下のような流れで処理をします。

interactor - input port
-> gateway (データベースの処理)
-> presenter - output port (出力の処理)

何が良いの??

今回は、HTTP でリクエストを受けて、MySQL で処理後、HTTP でレスポンスを返します。
しかし、MySQL から PostgreSQL に変えたくなるかもしれないし、
HTTP レスポンスから CSV 出力に変えたくなるかもしれないです。
なので、下の図のように、パズルみたいに変えられたらめっちゃ嬉しい訳です。

以下に何も考えず実装したコードを載せてみました。
1 つの関数に、リクエストの処理、データベースの処理、レスポンスの処理が完結していて、かなり分かりやすいと感じます。
しかし、通常の何も考えないで実装をする場合、いろんなところに MySQL のライブラリの関数が使われているから、パズルみたいに簡単に付け替えることはできません。

func (s *API) GetUsers(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	query := "SELECT * FROM user"
	rows, err := s.db.QueryContext(ctx, query)
	if err != nil {
		log.Printf("[ERROR] not found User: %+v", err)
		writeHTTPError(w, http.StatusInternalServerError)
		return
	}

	users := make([]ResponceGetUser, 0)
	for rows.Next() {
		var v User
		if err := rows.Scan(&v.ID, &v.Name, &v.Address, &v.Status, &v.Password, &v.ChatNumber, &v.Token, &v.CreatedAT, &v.UpdatedAt); err != nil {
			log.Printf("[ERROR] scan user: %+v", err)
			writeHTTPError(w, http.StatusInternalServerError)
			return
		}

		user := ResponceGetUser{
			ID:         v.ID,
			Name:       v.Name,
			Status:     v.Status,
			ChatNumber: v.ChatNumber,
			CreatedAT:  v.CreatedAT,
		}

		users = append(users, user)
	}

	resp := &ResponseGetUsers{
		Users: users,
	}

	// レスポンスを返す。
	if err := json.NewEncoder(w).Encode(&resp); err != nil {
		log.Printf("[ERROR] response encoding failed: %+v", err)
		writeHTTPError(w, http.StatusInternalServerError)
		return
	}
}

しかし、クリーンアーキテクチャの場合、具体的な実装 (MySQL のライブラリが使われている場所) は、「Gateway」 しかないので、そこだけ編集すれば良いんです。

「具体的な実装部分」 と、「抽象的な実装部分」 がはっきり分かれているおかげで、簡単な実装変更ができます。そのため、実装時はいろいろ考えないといけないので大変です...

環境

macOS Monterey 12.6.2
go version go1.19.1
mysql 8.0.30

実際に実装する

今回は、チャットサービスのバックエンドを想定しています。
/api/users にリクエストが飛ぶと、ユーザーの一覧を返すエンドポイントを例にあげて説明します。
interface の初期化等のコードは省略します。

1. driver 層を実装

リクエストが来たら、エンドポイントで処理を変えます。
下の階層 (controller 層) の関数を呼ぶ感じです。
今回は Chi を使います。

r.Route("/api", func(r chi.Router) {

	// GET: /api/users
	r.Get("/users", s.Controller.GetUsers(ctx))

	// GET: /api/signup/user
	r.Get("/signup/user", s.Controller.SignUpUser(ctx))

	r.Route("/login", func(r chi.Router) {
		// GET: /api/login/user
		r.Get("/user", s.Controller.LoginUser(ctx))
	})

	// POST: /api/user/profile
	r.Post("/user/profile", s.Controller.EditProfile(ctx))
})

2. controller 層を実装

interactor の input port の関数を呼びます。

func (s *ServiceController) GetUsers(ctx context.Context) func(w http.ResponseWriter, r *http.Request) {
	handleFunc := func(w http.ResponseWriter, r *http.Request) {
		s.NewInputPort(w, r).GetUsersInputPort(ctx)
	}

	return handleFunc
}

3. usecase 層 - interface を実装

ここからが肝です。
input port, repository(gateway), output port のインターフェースを定義します。

type ServiceInputPort interface {
	GetUsersInputPort(context.Context)
}

4. usecase 層 - input port を実装

個人的には input port にクリーンアーキテクチャの全てが詰まっていると思います。
useacase には、具体的なライブラリを使用した関数は使わないように注意しましょう。
感覚的には、データベースの処理と、出力に関する処理を書くイメージです。

// User を取得する抽象度が高い関数
// repo から User を取得 -> output port に渡す
func (s *ServiceInput) GetUsersInputPort(ctx context.Context) {
	users := s.Repository.GetUsersRepository(ctx)
	s.OutputPort.GetUsersOutputPort(users)
}

5. adapter 層 - gateway を実装

今回は、データベースのアクセスする関数として、「全てのユーザーを取得する」 という機能が欲しいです。
なので、以下のように、ポートにインターフェースを定義します。

type ServiceInputPort interface {
	GetUsersInputPort(context.Context)
}

type ServiceRepository interface {
	GetUsersRepository(context.Context) []entity.User
}

実装は、adapter/gateway 配下にファイルを用意してあげます。
今回は、すべてのユーザーを取得するという SQL を書きます。

func (s ServiceRepo) GetUsersRepository(ctx context.Context) []entity.User {
	query := "SELECT * FROM user"
	rows, err := s.conn.QueryContext(ctx, query)
	if err != nil {
		log.Printf("[ERROR] not found User: %+v", err)
		return nil
	}

	users, _, err := ScanUsers(rows)
	if err != nil {
		log.Printf("[ERROR] can not scan User: %+v", err)
		return nil
	}

	return users
}

6. adapter 層 - presenters を実装

出力として、「すべてのユーザーを引数に受けて、ユーザーを返す」というユースケースがあります。
ユースケースを元に interface を定義して、実装は adapter 層に書きます。

type ServiceInputPort interface {
	GetUsersInputPort(context.Context)
}

type ServiceRepository interface {
	GetUsersRepository(context.Context) []entity.User
}

type ServiceOutputPort interface {
	GetUsersOutputPort([]entity.User)
}

今回は、HTTP のレスポンスとしてユーザーを JSON 形式で返すことを想定しているので、実装は以下の通りになります。

func (s *ServiceOutput) GetUsersOutputPort(users []entity.User) {
	type getUser struct {
		Name       string `json:"name"`
		ID         string `json:"ID"`
		ChatNumber int    `json:"chatNumber"`
	}

	res := struct {
		User []getUser `json:"Users"`
	}{}

	var getUsers []getUser
	for _, e := range users {
		getUsers = append(getUsers, getUser{
			Name:       e.Name,
			ID:         e.ID,
			ChatNumber: e.ChatNumber,
		})
	}

	res.User = getUsers

	if err := json.NewEncoder(s.w).Encode(&res); err != nil {
		log.Printf("[ERROR] response encoding failed: %+v", err)
		entity.WriteHTTPError(s.w, http.StatusInternalServerError)
	}

}

まとめ

クリーンアーキテクチャはメリットを感じられた!
ソースコードは以下です。

Discussion