• トップ
  • ブログ一覧
  • 【Go言語】クリーンアーキテクチャで作るREST API
  • 【Go言語】クリーンアーキテクチャで作るREST API

    りゅうちゃん(エンジニア)りゅうちゃん(エンジニア)
    2022.03.15

    IT技術

    【Go言語(Golang)】クリーンアーキテクチャで作るREST API

    以前に投稿された以下の笹川先生の実装をクリーンアーキテクチャで作り替えてみよう!という内容です。

    featureImg2019.10.09【第5回】Go言語(Golang)入門~REST API実装編~第5回~Go言語(Golang)入門~笹川先生(株)ライトコードの笹川(ささがわ)です!前回は、RESR APIを構築...

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

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

    参考:https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

    クリーンアーキテクチャで調べるとよく出てくる例の円ですね。

    ざっくり説明すると、円の中の各項目は矢印の向き(内側)に依存(参照)するようにして、特定の項目の仕様変更が他の項目に影響を及ぼさないようにしています。

    それによってそれぞれが外部の項目を気にすることなくテストできたり、仕様変更の影響範囲が少なくなったりといった利点があります。

    実装:クリーンアーキテクチャ化

    では早速、クリーンアーキテクチャ化を進めていきましょう!

    Entities

    外側は内側の参照を持つため、内側から順に実装していきます。

    1package entities
    2
    3type User struct {
    4	Name    string `json:"name"`
    5	Age     int    `json:"age"`
    6	Address string `json:"address"`
    7}

    こちらはそのままUserの構造体を入れているだけですね。

    笹川先生のものと同じく、JSONでの受け取りからJSONでの返却まで、このUser構造体でやりとりするようにします。

    Use Cases

    UseCasesではFirestoreだったりecho(Http通信)だったりの技術的な要素を省いた、ビジネスルールを記載していきます。

    具体的には、今回作成するaddUserの実装は以下のようになっているので、

    1.  echoでユーザーデータ(JSON)を受け取り
    2. Firestoreにユーザーデータを追加
    3. echoで全てのユーザーデータ(JSON)を返す or エラーを返す

    技術的要素を省いて、

    1.  ユーザーデータを受け取り
    2. 何かしらにユーザーデータを追加
    3. 全てのユーザーデータを返却する or エラーを返す

    といった感じに実装していきます。

    また、addUserで全てのユーザーを返却するついでに、全ユーザー返却単品のものも追加してあります。

    1package ports
    2
    3import (
    4	"context"
    5	"firestore_clean/entities"
    6)
    7
    8// 1. ユーザーデータを受け取り
    9type UserInputPort interface {
    10	AddUser(ctx context.Context, user *entities.User) error
    11	GetUsers(ctx context.Context) error
    12}
    13
    14// 3. 全てのユーザーを返却 or エラーを返す
    15type UserOutputPort interface {
    16	OutputUsers([]*entities.User) error
    17	OutputError(error) error
    18}
    19
    20// 2. 何かしらにユーザーデータを追加、全ユーザーを返す
    21type UserRepository interface {
    22	AddUser(ctx context.Context, user *entities.User) ([]*entities.User, error)
    23	GetUsers(ctx context.Context) ([]*entities.User, error)
    24}

    Portではinterfaceのみを記述し、実体は別のgoファイルで実装します。

    次は、同じくUseCasesのInteractorです。

    1package interactors
    2
    3import (
    4	"context"
    5	"firestore_clean/entities"
    6	"firestore_clean/usecases/ports"
    7)
    8
    9type UserInteractor struct {
    10	OutputPort ports.UserOutputPort
    11	Repository ports.UserRepository
    12}
    13
    14func NewUserInputPort(outputPort ports.UserOutputPort, repository ports.UserRepository) ports.UserInputPort {
    15	return &UserInteractor{
    16		OutputPort: outputPort,
    17		Repository: repository,
    18	}
    19}
    20
    21func (u *UserInteractor) AddUser(ctx context.Context, user *entities.User) error {
    22	users, err := u.Repository.AddUser(ctx, user)
    23	if err != nil {
    24		return u.OutputPort.OutputError(err)
    25	}
    26
    27	return u.OutputPort.OutputUsers(users)
    28}
    29
    30func (u *UserInteractor) GetUsers(ctx context.Context) error {
    31	users, err := u.Repository.GetUsers(ctx)
    32	if err != nil {
    33		return u.OutputPort.OutputError(err)
    34	}
    35
    36	return u.OutputPort.OutputUsers(users)
    37}

    Interactorでは、ユーザーデータを受け取るInputPortの実体と、先ほどの技術的要素を省いた1~3の手順を実装します。

    技術的要素を省いた箇所についてはinterfaceを呼び出すことよって、UseCasesの外側にある技術的要素を隠蔽していますね。

    これを依存性逆転の原則といい、円の外側への依存を回避した上で、コード変更の影響を最小限にすることができます。

    Presenters

    Presentersでは、OutputPortの実体を実装します。

    また、ここからは技術的要素を含んでの実装になるので、echoやFirestore関連の実装も行なっていきます。

    1package presenters
    2
    3import (
    4	"firestore_clean/entities"
    5	"firestore_clean/usecases/ports"
    6	"log"
    7	"net/http"
    8
    9	"github.com/labstack/echo/v4"
    10)
    11
    12type UserPresenter struct {
    13	ctx echo.Context
    14}
    15
    16func NewUserOutputPort(ctx echo.Context) ports.UserOutputPort {
    17	return &UserPresenter{
    18		ctx: ctx,
    19	}
    20}
    21
    22func (u *UserPresenter) OutputUsers(users []*entities.User) error {
    23	return u.ctx.JSON(http.StatusOK, users)
    24}
    25
    26func (u *UserPresenter) OutputError(err error) error {
    27	log.Fatal(err)
    28	return u.ctx.JSON(http.StatusInternalServerError, err)
    29}

    出力自体は、echoが行ってくれるので、echoのPOSTメソッド等でreturnする値を定義しています。

    今回実装するREST APIでは、「全てのユーザーを返す」or 「エラーを返す」しかないので、この2パターンのみ実装していますね。

    Gateways

    Gatewaysでは、DB操作の実体を実装します。

    なので、Firestore関連の実装をする感じになりますね。

    1package gateways
    2
    3import (
    4	"context"
    5	"encoding/json"
    6	"errors"
    7	"firestore_clean/entities"
    8	"firestore_clean/usecases/ports"
    9	"fmt"
    10
    11	"cloud.google.com/go/firestore"
    12)
    13
    14type FirestoreClientFactory interface {
    15	NewClient(ctx context.Context) (*firestore.Client, error)
    16}
    17
    18type UserGateway struct {
    19	clientFactory FirestoreClientFactory
    20}
    21
    22func NewUserRepository(clientFactory FirestoreClientFactory) ports.UserRepository {
    23	return &UserGateway{
    24		clientFactory: clientFactory,
    25	}
    26}
    27
    28func (gateway *UserGateway) AddUser(ctx context.Context, user *entities.User) ([]*entities.User, error) {
    29	if user == nil {
    30		return nil, errors.New("user is nil")
    31	}
    32
    33	client, err := gateway.clientFactory.NewClient(ctx)
    34	if err != nil {
    35		return nil, fmt.Errorf("failed AddUser NewClient: %v", err)
    36	}
    37	defer client.Close()
    38
    39	_, err = client.Collection("users").Doc(user.Name).Set(ctx, map[string]interface{}{
    40		"age":     user.Age,
    41		"address": user.Address,
    42	})
    43
    44	if err != nil {
    45		return nil, fmt.Errorf("failed AddUser Set: %v", err)
    46	}
    47
    48	return getUsers(ctx, client)
    49}
    50
    51func (gateway *UserGateway) GetUsers(ctx context.Context) ([]*entities.User, error) {
    52	client, err := gateway.clientFactory.NewClient(ctx)
    53	if err != nil {
    54		return nil, fmt.Errorf("failed GetUsers NewClient: %v", err)
    55	}
    56	defer client.Close()
    57
    58	return getUsers(ctx, client)
    59}
    60
    61func getUsers(ctx context.Context, client *firestore.Client) ([]*entities.User, error) {
    62	allData := client.Collection("users").Documents(ctx)
    63
    64	docs, err := allData.GetAll()
    65	if err != nil {
    66		return nil, fmt.Errorf("failed GetUsers GetAll: %v", err)
    67	}
    68
    69	users := make([]*entities.User, 0)
    70	for _, doc := range docs {
    71		u := new(entities.User)
    72		err = mapToStruct(doc.Data(), &u)
    73		if err != nil {
    74			return nil, fmt.Errorf("failed GetUsers mapToStruct: %v", err)
    75		}
    76		u.Name = doc.Ref.ID
    77		users = append(users, u)
    78	}
    79
    80	return users, nil
    81}
    82
    83// map -> 構造体の変換
    84func mapToStruct(m map[string]interface{}, val interface{}) error {
    85	tmp, err := json.Marshal(m)
    86	if err != nil {
    87		return err
    88	}
    89	err = json.Unmarshal(tmp, val)
    90	if err != nil {
    91		return err
    92	}
    93	return nil
    94}

    ここはほとんど、笹川先生が実装したものと同じですね。

    少し違う点としては、NewUserRepositoryでFirestoreのClientの初期化用interfaceを要求することで、AddUserとGetUsersそれぞれでFirestoreClientの初期化とClose処理を行うようにしています。

    これで本番環境と開発環境を外部から変更することが可能になりますね。

    また、errorを返却する際にはfmt.Errorf でerror文を加工し、PresentersのOutputErrorに渡した際にそのerrorを表示するようにすることで、出力関連の処理を全てPresentersに任せるようにしています。

    Controllers

    Controllersでは、InputPort・OutputPort・Repositoryを組み立てて、InputPortを実行します。

    1package controllers
    2
    3import (
    4	"context"
    5	"firestore_clean/adapters/gateways"
    6	"firestore_clean/entities"
    7	"firestore_clean/usecases/ports"
    8
    9	"github.com/labstack/echo/v4"
    10)
    11
    12type User interface {
    13	AddUser(ctx context.Context) func(c echo.Context) error
    14	GetUsers(ctx context.Context) func(c echo.Context) error
    15}
    16
    17type OutputFactory func(echo.Context) ports.UserOutputPort
    18type InputFactory func(ports.UserOutputPort, ports.UserRepository) ports.UserInputPort
    19type RepositoryFactory func(gateways.FirestoreClientFactory) ports.UserRepository
    20
    21type UserController struct {
    22	outputFactory     OutputFactory
    23	inputFactory      InputFactory
    24	repositoryFactory RepositoryFactory
    25	clientFactory     gateways.FirestoreClientFactory
    26}
    27
    28func NewUserController(outputFactory OutputFactory, inputFactory InputFactory, repositoryFactory RepositoryFactory, clientFactory gateways.FirestoreClientFactory) User {
    29	return &UserController{
    30		outputFactory:     outputFactory,
    31		inputFactory:      inputFactory,
    32		repositoryFactory: repositoryFactory,
    33		clientFactory:     clientFactory,
    34	}
    35}
    36
    37func (u *UserController) AddUser(ctx context.Context) func(c echo.Context) error {
    38	return func(c echo.Context) error {
    39		user := new(entities.User)
    40		if err := c.Bind(user); err != nil {
    41			return err
    42		}
    43
    44		return u.newInputPort(c).AddUser(ctx, user)
    45	}
    46}
    47
    48func (u *UserController) GetUsers(ctx context.Context) func(c echo.Context) error {
    49	return func(c echo.Context) error {
    50		return u.newInputPort(c).GetUsers(ctx)
    51	}
    52}
    53
    54func (u *UserController) newInputPort(c echo.Context) ports.UserInputPort {
    55	outputPort := u.outputFactory(c)
    56	repository := u.repositoryFactory(u.clientFactory)
    57	return u.inputFactory(outputPort, repository)
    58}

    UserControllerのメソッドであるAddUserとGetUsersでは、echo.POSTの第二引数の型であるfunc(c echo.Context) error を返しています。

    なので、基本的には元々そちらに書かれていた内容を満たすような実装をしているだけですね。

    Database(Firestore)

    これは単純に、Firestoreのクライアントを取得するための実装です。

    1package database
    2
    3import (
    4	"context"
    5
    6	"cloud.google.com/go/firestore"
    7	firebase "firebase.google.com/go"
    8	"google.golang.org/api/option"
    9)
    10
    11type MyFirestoreClientFactory struct{}
    12
    13func (f *MyFirestoreClientFactory) NewClient(ctx context.Context) (*firestore.Client, error) {
    14	sa := option.WithCredentialsFile("credentialsFile.json")
    15	app, err := firebase.NewApp(ctx, nil, sa)
    16	if err != nil {
    17		return nil, err
    18	}
    19
    20	client, err := app.Firestore(ctx)
    21	if err != nil {
    22		return nil, err
    23	}
    24	return client, nil
    25}

    Gatewaysで宣言している、FirestoreClientFactoryを実装している感じですね。

    Gatewaysの項目でも説明しましたが、これで容易に外部から本番環境とか開発環境の変更をすることが可能です。

    Drivers

    Driversでは、Controllerを呼び出し、echoの設定を行います。

    1package drivers
    2
    3import (
    4	"context"
    5	"firestore_clean/adapters/controllers"
    6
    7	"github.com/labstack/echo/v4"
    8)
    9
    10type User interface {
    11	ServeUsers(ctx context.Context, address string)
    12}
    13
    14type UserDriver struct {
    15	echo       *echo.Echo
    16	controller controllers.User
    17}
    18
    19func NewUserDriver(echo *echo.Echo, controller controllers.User) User {
    20	return &UserDriver{
    21		echo:       echo,
    22		controller: controller,
    23	}
    24}
    25
    26func (driver *UserDriver) ServeUsers(ctx context.Context, address string) {
    27	driver.echo.POST("/users", driver.controller.AddUser(ctx))
    28	driver.echo.GET("/users", driver.controller.GetUsers(ctx))
    29	driver.echo.Logger.Fatal(driver.echo.Start(address))
    30}

    今回は、笹川先生の記事にはなかったツールとして、wireを使用してみました。

    DIする時の面倒な初期化処理を自動で生成してくれるツールです。

    1//go:build wireinject
    2// +build wireinject
    3
    4package drivers
    5
    6import (
    7	"context"
    8	"firestore_clean/adapters/controllers"
    9	"firestore_clean/adapters/gateways"
    10	"firestore_clean/adapters/presenters"
    11	"firestore_clean/database"
    12	"firestore_clean/usecases/interactors"
    13
    14	"github.com/google/wire"
    15	"github.com/labstack/echo/v4"
    16)
    17
    18func InitializeUserDriver(ctx context.Context) (User, error) {
    19	wire.Build(NewFirestoreClientFactory, echo.New, NewOutputFactory, NewInputFactory, NewRepositoryFactory, controllers.NewUserController, NewUserDriver)
    20	return &UserDriver{}, nil
    21}
    22
    23func NewFirestoreClientFactory() database.FirestoreClientFactory {
    24	return &database.MyFirestoreClientFactory{}
    25}
    26
    27func NewOutputFactory() controllers.OutputFactory {
    28	return presenters.NewUserOutputPort
    29}
    30
    31func NewInputFactory() controllers.InputFactory {
    32	return interactors.NewUserInputPort
    33}
    34
    35func NewRepositoryFactory() controllers.RepositoryFactory {
    36	return gateways.NewUserRepository
    37}

    上記のコードを書いた後、drivers上でwireコマンドを実行することで、以下のコードが生成されます。

    1// Code generated by Wire. DO NOT EDIT.
    2
    3//go:generate go run github.com/google/wire/cmd/wire
    4//go:build !wireinject
    5// +build !wireinject
    6
    7package drivers
    8
    9import (
    10	"context"
    11	"firestore_clean/adapters/controllers"
    12	"firestore_clean/adapters/gateways"
    13	"firestore_clean/adapters/presenters"
    14	"firestore_clean/database"
    15	"firestore_clean/usecases/interactors"
    16	"github.com/labstack/echo/v4"
    17)
    18
    19// Injectors from wire.go:
    20
    21func InitializeUserDriver(ctx context.Context) (User, error) {
    22	echoEcho := echo.New()
    23	outputFactory := NewOutputFactory()
    24	inputFactory := NewInputFactory()
    25	repositoryFactory := NewRepositoryFactory()
    26	firestoreClientFactory := NewFirestoreClientFactory()
    27	user := controllers.NewUserController(outputFactory, inputFactory, repositoryFactory, firestoreClientFactory)
    28	driversUser := NewUserDriver(echoEcho, user)
    29	return driversUser, nil
    30}
    31
    32// wire.go:
    33
    34func NewFirestoreClientFactory() gateways.FirestoreClientFactory {
    35	return &database.MyFirestoreClientFactory{}
    36}
    37
    38func NewOutputFactory() controllers.OutputFactory {
    39	return presenters.NewUserOutputPort
    40}
    41
    42func NewInputFactory() controllers.InputFactory {
    43	return interactors.NewUserInputPort
    44}
    45
    46func NewRepositoryFactory() controllers.RepositoryFactory {
    47	return gateways.NewUserRepository
    48}

    Controllerを初期化するために各interfaceの実装を指定して、最終的にDriversの初期化を行っているだけですね。

    main.go

    最後に、main.goでuser_driverを呼び出してみましょう!

    1package main
    2
    3import (
    4	"context"
    5	"firestore_clean/drivers"
    6	"fmt"
    7	"os"
    8)
    9
    10func main() {
    11	ctx := context.Background()
    12	userDriver, err := drivers.InitializeUserDriver(ctx)
    13	if err != nil {
    14		fmt.Printf("failed to create UserDriver: %s\n", err)
    15		os.Exit(2)
    16	}
    17	userDriver.ServeUsers(ctx, ":8000")
    18}

    ここまで複雑な構造でしたが、main.goから見るとすごい簡単ですね!

    動作確認

    Postmanで確認してみると、以下のようになりました。

    GetUsers

    GetUsers

    AddUser

    AddUser

    ちゃんと動作してることが確認できました!

    まとめ

    取り扱ったコードは結構単純な処理だったので、クリーンアーキテクチャ化の恩恵は少し薄いかもしれませんが、仕様変更に強く、テストが書きやすいコードができたと思います。

    今回のコードは以下のリポジトリにまとめてありますので、ぜひ確認してみてください!

    https://github.com/ryuto-imai/firestore_clean_architecture

    りゅうちゃん(エンジニア)

    りゅうちゃん(エンジニア)

    おすすめ記事

    GitHubActionsのランナーに触れてみた

    こやまん(エンジニア)

    こやまん(エンジニア)

    2024.03.28

    IT技術

    Azure Data FactoryでSlackへ通知をしてみる

    たかやん(エンジニア)

    たかやん(エンジニア)

    2024.03.28

    IT技術

    GCP Secret Managerを使ってみた

    たなゆー(エンジニア)

    たなゆー(エンジニア)

    2024.03.21

    IT技術

    Bitriseのパイプラインと環境変数

    加納(エンジニア)

    加納(エンジニア)

    2024.03.11

    IT技術