【Go】レイアードアーキテクチャーによる構成案を考えてみた
こんにちは、@nerusanです。
最近は、Golangを触ることが多いので、Golangについての記事を書いていきたいと思います。
Golangは、軽量かつシンプルなので、比較的簡単にAPIなどを実装できます。
そういうのもあってか、最近は採用している企業などは多い印象です。
Webフレームワークも、Gin, echoなど豊富にあります。
モジュール、ライブラリが豊富なのもGolangが人気である理由の一つです。
ただ、Webフレームワークは、フレームワークと言いつつ、LaravelやRuby on Railsなどとは異なり、自身でアーキテクチャーを考える必要があります。
今回は、そのアーキテクチャー・設計や設計を考えたので、紹介できたらと思います。
一部ドメイン駆動開発(DDD)的な考え方を取り入れているので、そちらも同時に紹介できたらと思います。
全体の設計
フロントエンドとバックエンドは密接な関係です。
そこで、その両方をみた上で、どういう設計をすべきなのかを述べたいと思います。
アプリケーションサービス全体で、拡張性・保守性があり、パフォーマンス効率を考えた設計をすることが大事になるかなって思います。
そのためにまず、フロントエンド側にビジネスロジックを記載する利口なUIはできるだけ避けるべきであり。ビジネスロジックは、できるだけサーバー側に置くことが大事になります。
また、ビジネスロジックは、ドメイン単位で作ることで重複は避けることができます。
フロントエンドにビジネスロジックを置いたときの弊害として、2点あるかなって考えています。
1点フロントエンドは画面単位で作成するので、同じビジネスロジックが重複しやすく、ビジネスロジックが変更になった場合、広範囲に広がります。
例えば、商品の合計値を計算するロジックを考えたとき、購入履歴画面、購入確認画面、購入一覧画面の3画面で表示することを想定します。それぞれの画面で異なる人が実装すると、それぞれ、その画面に紐付いたところに合計値を計算するロジックが記述されます。それで、消費税の計算方法が変わったときに、3つそれぞれ変更する必要があります。そうすると、修正時間が膨らみ、修正の漏れ、そもそも記述コードの量が増えるため、パフォーマンスも悪くなります。
2点目は、UIはより変更されやすく、かつ、変更されやすくすべきだが、ビジネスロジックがUIに絡むと変更が難しくなります。
モダンなデザインは移り変わりは激しく、それに対応しないとUXは非常に悪くなります。なので、UIは、変更されやすくすべきです。例えば、商品価格の値だけを別のページに移動するってなった際、ロジックが紐づいているとそのロジックも移動することになります。しかし、そのロジックは、他のロジックに依存していた場合、可読性が下がることが容易に想像できます。
ビジネスロジックをバックエンド側に置くことで、
フロントエンドは、UIに関するロジック、a11yやパフォーマンスチューニング、SEOに注力することができ、結果UXも上がると考えます。
また、フロントエンド側にロジックを増やさないように、マジックナンバーを返すこともなるべく控えるようにします。
0とか1が返されても、フロント側では、それをわかりやすくするために、変換するためロジックが発生します。
// 0とはどういう状態?
// 1はadmin?
{
status: 0,
permission: 1,
}
// わかりやすい
{
status: 'loading',
permission: 'admin',
}
以上、細かい部分もありましたが、全体としての設計の考えを述べました。
どんなアーキテクチャーにしたの?
さてここからは、アーキテクチャーの話です。
今回、、レイヤードアーキテクチャーを採用しました。
レイヤードとは層という意味ですが、以下の層に分けて、アプリケーションを構築します。
- ユーザインターフェース
- アプケーション
- ドメイン
- インフラストラクチャー
上ほど抽象度が高くなっており、上位レイヤーが下位レイヤーを利用します。
利用の向きは以下の通りです。
ユーザインターフェース
↓
アプリケーション
↓
ドメイン
↓
インフラ
ただ、依存関係は以下の通りとなります。
ユーザインターフェース
↓
アプリケーション
↓
ドメイン
↑
インフラ
ドメインやアプリケーションからインフラストラクチャーへの依存はインターフェースを通して利用するため、インフラからドメインに依存しています。
そのことを、依存関係性の逆転と言います。詳しくは以下の記事に書いていますので、
見てみてください。
各層を詳しく見てみましょう。
ユーザインターフェース
第三者の利用者とサービスをつなげるインターフェース部分です。
クライアント側の入力を受け取り、ユーザ側に結果を返す役割を担います。
アプリケーション
ユースケースの進行役を担います。
進行役のため、ドメインのルールやロジックは禁止です。
例えば、ユーザ名は50文字までいったことや、「|[{<>}]?!@#$%^&*()_」は禁止文字であることは記述禁止です。
ユースケースなので、たとえば、ユーザを登録登録の一連処理、商品を購入する一連処理を記述するイメージです。
ドメイン
ドメインに関することを書きます。
例えば、「ユーザ名」、「パスワード」、「商品」があり、それぞれ50文字であることや、利用禁止文字があるなどのルール(ロジック)があれば、記述します。
ドメイン駆動開発では、ここが一番大切。ここをみれば、どういった仕様なのか?知れるようにすることが大事になってきます。
コードそのものをドキュメントとする考えとなっており、別途ドキュメントを残す必要がないことを目指します。
別途ドキュメントを残すと、更新漏れがあったり、記述ミスがある可能性があったり、手間が増える。コードそのものをドキュメントとすることが一番確実になります。
インフラストラクチャー
ここでは、他の層を支える技術的な基盤を描きます。
データベースやキャッシュサーバーなどの処理などです。
DBに依存する記述やSQLなどはこの層に書きます。
抽象度が高い存在(ユーザを新規登録する、など)にとって、何に保存するか(MySQL、Postgressなど)は関係なくどうでも良いです。何かの媒体を使って保存してくれることのみが大事。なので、その情報を切り分けることで、変更に強くなります。
ディレクトリ構成
では、実際にレイヤードアーキテクチャーに則った、ディレクトリ構成を考えてみます。
ここで、メールアドレスとパスワードでリクエストを送り、成功したらJWTを返すエンドポイントのコードの例とともに 紹介します。
ハンドラー(/handler)
レイヤードアーキテクチャーでいうと、ユーザインターフェースの部分になります。
具体的には、以下のことを行います。
- クライアントからきたデータのバリデーションを行う
- クライアントにステータスなどを含めたデータ返す
- ユースケートの依頼はインターフェースを利用してサービス層に依頼します。
package handler
import (
"errors"
"net/http"
"regexp"
"github.com/gin-gonic/gin"
validation "github.com/go-ozzo/ozzo-validation"
"github.com/go-ozzo/ozzo-validation/is"
"github.com/hack-31/point-app-backend/repository"
)
type Signin struct {
Service SigninService
}
func NewSigninHandler(s SigninService) *Signin {
return &Signin{Service: s}
}
// サインインハンドラー
//
// @param ctx ginContext
func (ru *Signin) ServeHTTP(ctx *gin.Context) {
// ユーザのリクエストパラメータを構造体にマッピング
var input struct {
Email string `json:"email"`
Password string `json:"password"`
}
const errTitle = "サインインエラー"
if err := ctx.ShouldBindJSON(&input); err != nil {
ErrResponse(ctx, http.StatusBadRequest, errTitle, err.Error())
return
}
// リクエストパラメータをバリデーション
err := validation.ValidateStruct(&input,
validation.Field(
&input.Email,
validation.Length(1, 256),
validation.Required,
is.Email,
),
validation.Field(
&input.Password,
validation.Length(8, 50),
validation.Match(regexp.MustCompile(``)),
validation.Required,
),
)
if err != nil {
ErrResponse(ctx, http.StatusBadRequest, errTitle, err.Error())
return
}
// パラメータが正しいことが確認できたら、
// サインイン処理はサービスに依頼
jwt, err := ru.Service.Signin(ctx, input.Email, input.Password)
if err != nil {
if errors.Is(err, repository.ErrNotMatchLogInfo) {
ErrResponse(ctx, http.StatusUnauthorized, errTitle, repository.ErrNotMatchLogInfo.Error())
return
}
ErrResponse(ctx, http.StatusInternalServerError, errTitle, err.Error())
return
}
// 成功レスポンスを返す
rsp := struct {
Token string `json:"accessToken"`
}{Token: jwt}
APIResponse(ctx, http.StatusCreated, "サインイン成功しました。", rsp)
}
サービス(/service)
レイヤードアーキテクチャーでいうと、アプリケーションの部分になります。
具体的には、以下のことを行います。
- ドメインとリポジトリを利用してユースケース(機能)の実装を行う
- リポジトリは、インターフェースを通して利用する
- ビジネスロジックは記述しない
package service
import (
"context"
"fmt"
"github.com/hack-31/point-app-backend/domain"
"github.com/hack-31/point-app-backend/domain/model"
"github.com/hack-31/point-app-backend/repository"
)
type Signin struct {
DB repository.Queryer
Cache domain.Cache
Repo domain.UserRepo
TokenGenerator domain.TokenGenerator
}
// サインインサービス
//
// @params
// ctx コンテキスト
// email メール
// password パスワード
//
// @return
// JWT
func (s *Signin) Signin(ctx context.Context, email, password string) (string, error) {
// emailよりリポジトリを通してユーザ情報を取得
u, err := s.Repo.FindUserByEmail(ctx, s.DB, &email)
if err != nil {
return "", fmt.Errorf("failed to find user : %w", repository.ErrNotMatchLogInfo)
}
// パスワードが一致するか確認
pwd, err := model.NewPasswrod(password)
if err != nil {
return "", fmt.Errorf("cannot create password object: %w", err)
}
if isMatch, _ := pwd.IsMatch(u.Password); !isMatch {
return "", fmt.Errorf("no match passwrod: %w", repository.ErrNotMatchLogInfo)
}
// JWTを作成
jwt, err := s.TokenGenerator.GenerateToken(ctx, u)
if err != nil {
return "", fmt.Errorf("failed to generate JWT: %w", err)
}
return string(jwt), nil
}
ドメイン(/domain)
レイヤードアーキテクチャーでいうと、ドメインの部分になります。
具体的には以下のことを行います。
- ユーザや、パスワードなどサービス間をまたがるドメインは関するロジックなど記述をする
- リポジトリは、インターフェースを通して利用する
- リポジトリのインターフェースを格納
ドメイン駆動開発では、値オブジェクト、エンティティ、ドメインサービスがあります。
今回、 値オブジェクト、エンティティはdomain/model
、ドメインサービスはdomain/service
に格納するとしました。
サービスドメインを少し解説します。
値オブジェクトやエンティティに記述するとドメイン的に不自然になる場合はドメインサービスに記述します。
不自然な振る舞いの例として以下があります。
ユーザの重複を確認することを考える
ユーザの重複をエンティティに記述すると、ユーザー自身に自身の重複を確認するという現実世界ではおかしな振る舞いになる
その場合、ユーザサービスドメインに記述する
ドメインサービスは、できる限りは使わない方が良いとされるので、どうしても不自然である場合のみ利用するように心がけましょう。
また、各ドメインは凝縮度を意識してを作成することが大事になります。
package model
import (
"fmt"
"math/rand"
"time"
"unicode/utf8"
"github.com/hack-31/point-app-backend/constant"
"golang.org/x/crypto/bcrypt"
)
// パスワードオブジェクト
type Password struct {
value string
}
// パスワードオブジェクト作成
// ハッシュ化されてない値を扱う
// コンストラクタ
//
// @params pwd パスワード
//
// @return パスワードオブジェクト
func NewPasswrod(pwd string) (*Password, error) {
// パスワードは50文字以下であるという仕様がすぐわかる
if 50 < utf8.RuneCountInString(pwd) {
return nil, fmt.Errorf("cannot use password over 51 char")
}
return &Password{value: pwd}, nil
}
// ハッシュ化されたパスワードと一致するか
// @params
// hashPwd ハッシュ化されたパスワード
func (pwd *Password) IsMatch(hashPwd string) (bool, error) {
err := bcrypt.CompareHashAndPassword([]byte(hashPwd), []byte(pwd.value))
if err != nil {
return false, err
}
return true, err
}
// ハッシュ化したパスアードを返却
func (pwd *Password) CreateHash() (string, error) {
pw, err := bcrypt.GenerateFromPassword([]byte(pwd.value), bcrypt.DefaultCost)
return string(pw), err
}
// 文字列
// @return
// パスワード
func (pwd *Password) String() string {
return string(pwd.value)
}
package domain
import (
"context"
"time"
"github.com/hack-31/point-app-backend/domain/model"
"github.com/hack-31/point-app-backend/repository"
)
// Userに対するインターフェース
type UserRepo interface {
FindUserByEmail(ctx context.Context, db repository.Queryer, e *string) (model.User, error)
RegisterUser(ctx context.Context, db repository.Execer, u *model.User) error
UpdatePassword(ctx context.Context, db repository.Execer, email, pass *string) error
FindUsers(ctx context.Context, db repository.Queryer) (model.Users, error)
}
// トークンに対するインターフェース
type TokenGenerator interface {
GenerateToken(ctx context.Context, u model.User) ([]byte, error)
}
// キャッシュに対するインターフェース
type Cache interface {
Save(ctx context.Context, key, value string, minute time.Duration) error
Load(ctx context.Context, key string) (string, error)
Delete(ctx context.Context, key string) error
}
リポジトリ(/repository)
レイヤードアーキテクチャーでいうと、インフラストラクチャーの部分になります。
具体的には以下のことをやります。
- ドメイン層で定義したインターフェースを実装する
- SQLをかいて、DBにアクセスする
- キャッシュサーバーへのアクセス
package repository
import (
"context"
"errors"
"fmt"
"github.com/go-sql-driver/mysql"
"github.com/hack-31/point-app-backend/domain/model"
)
// メールでユーザが存在するか検索する
// @params
// ctx context
// db dbインスタンス
// email email
//
// @returns
// model.User ユーザ情報
func (r *Repository) FindUserByEmail(ctx context.Context, db Queryer, email *string) (model.User, error) {
sql := `
SELECT
u.id,
u.first_name,
u.first_name_kana,
u.family_name,
u.family_name_kana,
u.email,
u.password,
u.created_at,
u.update_at,
u.sending_point,
SUM(IFNULL(t.transaction_point, 0)) AS acquisition_point
from users AS u
LEFT JOIN transactions AS t
ON u.id = t.receiving_user_id
GROUP BY u.id
HAVING u.email = ?
LIMIT 1`
var user model.User
if err := db.GetContext(ctx, &user, sql, email); err != nil {
return user, err
}
return user, nil
}
以上で、ディレクトリ構成と実際のソースコード例でした。
全体ツリー
上記ディレクトリ構成を含めた全体のディレクトリツリーは以下のようになります。
上記で示したディレクトリ以外もディレクトリが出てきていますね。
ここでは、/router
, /utils
, repository/transaction.go
について説明したいと思います。
他はコメントを見てもらえれば、そのままとなっているので、わかるかなって思います。
├── Dockerfile
├── Makefile
├── README.md
├── _tools # ツールの設定ファイルなど
│ └── mysql
│ ├── conf.d
│ │ ├── mysql.cnf
│ │ └── mysqld.cnf
│ ├── schema.sql
│ └── seed.sql
├── config # 環境変数の読み込み
│ └── config.go
├── constant # アプリケーション全体で利用する定数
│ └── constant.go
├── docker-compose.yml
├── docs # ドキュメント
│ ├── README.md
│ ├── openapi.yml # openapiの定義
│ └── swagger # swaggerをデプロイ用のディレクトリ
├── domain # ドメイン
│ ├── README.md
│ ├── interface.go # リポジトリのインテーフェース
│ ├── model # 値オブジェクト、エンティティ
│ │ ├── confirm_code.go
│ │ ├── password.go
│ │ ├── sendable_point.go
│ │ ├── temporary_user.go
│ │ └── user.go
│ └── service # ドメインサービス
│ └── user_service.go
├── go.mod
├── go.sum
├── handler # ハンドラー
│ ├── README.md
│ ├── get_account.go
│ ├── get_users.go
│ ├── health_check.go
│ ├── middleware.go
│ ├── register_temporary_user.go
│ ├── register_user.go
│ ├── reset_password.go
│ ├── response.go # レスポンス用フォーマットの定義
│ ├── send_point.go
│ ├── service.go # サービスのインターフェース
│ ├── signin.go
│ └── signout.go
├── main.go
├── repository # リポジトリ
│ ├── README.md
│ ├── error.go # DBによるエラーの定義
│ ├── kvs.go
│ ├── point.go
│ ├── repository.go # DBへの接続のための記述
│ ├── transaction.go # トランザクションを行うための記述
│ └── user.go
├── router # ルーティング
│ ├── README.md
│ ├── auth_router.go # 認証が必要なエンドポイント
│ └── router.go # 認証が不必要なエンドポイント
├── service # サービス
│ ├── README.md
│ ├── get_account.go
│ ├── get_users.go
│ ├── register_temporary_user.go
│ ├── register_user.go
│ ├── reset_password.go
│ ├── send_point.go
│ ├── signin.go
│ └── signout.go
└── utils # ユーティリティ
├── clock
│ └── clock.go
├── contains.go
└── email
└── email.go
/router
routerディレクトリでは、ルーティングの設定を行います。
ルーティングのパスの設定と、上記で作成したハンドラーとサービス、リポジトリのDIを行う部部になります。
package router
import (
"context"
"github.com/gin-gonic/gin"
"github.com/hack-31/point-app-backend/auth"
"github.com/hack-31/point-app-backend/config"
"github.com/hack-31/point-app-backend/handler"
"github.com/hack-31/point-app-backend/repository"
"github.com/hack-31/point-app-backend/service"
"github.com/hack-31/point-app-backend/utils/clock"
"github.com/jmoiron/sqlx"
)
// 認証がないルーティングの設定を行う
//
// @param
// ctx コンテキスト
// router ルーター
func SetRouting(ctx context.Context, db *sqlx.DB, router *gin.Engine, cfg *config.Config) error {
// レポジトリ
clocker := clock.RealClocker{}
rep := repository.Repository{Clocker: clocker}
// キャッシュ
cache, err := repository.NewKVS(ctx, cfg, repository.JWT)
if err != nil {
return err
}
// トークン
tokenCache, err := repository.NewKVS(ctx, cfg, repository.TemporaryUserRegister)
if err != nil {
return err
}
jwter, err := auth.NewJWTer(tokenCache, clocker)
if err != nil {
return err
}
// ルーティング設定
healthCheckhandler := handler.NewHealthCheckHandler()
router.GET("/healthcheck", healthCheckhandler.ServeHTTP)
groupRoute := router.Group("/api/v1")
registerHandler := handler.NewRegisterUserHandler(&service.RegisterUser{DB: db, Cache: cache, TokenGenerator: jwter, Repo: &rep})
groupRoute.POST("/users", registerHandler.ServeHTTP)
registerTempUser := handler.NewRegisterTemporaryUserHandler(&service.RegisterTemporaryUser{DB: db, Cache: cache, Repo: &rep})
groupRoute.POST("/temporary_users", registerTempUser.ServeHTTP)
signin := handler.NewSigninHandler(&service.Signin{DB: db, Cache: cache, Repo: &rep, TokenGenerator: jwter})
groupRoute.POST("/signin", signin.ServeHTTP)
resetPassword := handler.NewResetPasswordHandler(&service.ResetPassword{ExecerDB: db, QueryerDB: db, Repo: &rep})
groupRoute.PATCH("/random_password", resetPassword.ServeHTTP)
return nil
}
/utils
go言語では、配列を扱うスライスが用意されています。しかし、シンプルが故に、JavaScriptのincludesのような標準メソッドがあまり用意されていません。そのための自分で作成する必要があるのですが、作成したものを全体で使いたいので、ユーティリティに置きます。
package utils
import "reflect"
// スライスに含まれているかを判断する
// @params
// list リスト
// elem エレメント
//
// @returns
// true: 含まれる, false: 含まれない
func Contains(list interface{}, elem interface{}) bool {
listV := reflect.ValueOf(list)
if listV.Kind() == reflect.Slice {
for i := 0; i < listV.Len(); i++ {
item := listV.Index(i).Interface()
// 型変換可能か確認する
if !reflect.TypeOf(elem).ConvertibleTo(reflect.TypeOf(item)) {
continue
}
// 型変換する
target := reflect.ValueOf(elem).Convert(reflect.TypeOf(item)).Interface()
// 等価判定をする
if ok := reflect.DeepEqual(item, target); ok {
return true
}
}
}
return false
}
repository/transaction.go
DBを扱う場合は、トランザクションは必須になります。
トランザクション開始、終了、ロールバックなどトランザクションの操作は、処理の一連の流れに対して行う必要があります。
つまり、サービス層に記述する必要があります。
サービス層に、DB特有の記述を施すと、DB変更時の変更箇所が多くなります。
そこで、トランザクションのための操作は、ラッピングしたものを利用することで変更に強くしました。
package repository
import (
"context"
"database/sql"
"fmt"
)
type AppConnection struct {
// DBインスタンス
db Beginner
// トランザクションで利用するインスタンス
Tx *sql.Tx
}
func NewAppConnection(db Beginner) *AppConnection {
return &AppConnection{db: db}
}
// トラザクション開始
func (ac *AppConnection) Begin(ctx context.Context) error {
tx, err := ac.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("cannet connect transaction: %w", err)
}
ac.Tx = tx
return nil
}
// コミット
// トランザクションの最後に実行
func (ac *AppConnection) Commit() error {
return ac.Tx.Commit()
}
// ロールバック
// トラザクションを開いてから、エラーが起きた時に実行する
func (ac *AppConnection) Rollback() error {
return ac.Tx.Rollback()
}
package service
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/hack-31/point-app-backend/auth"
"github.com/hack-31/point-app-backend/domain"
"github.com/hack-31/point-app-backend/domain/model"
"github.com/hack-31/point-app-backend/repository"
)
type SendPoint struct {
PointRepo domain.PointRepo
UserRepo domain.UserRepo
Connection *repository.AppConnection
DB repository.Queryer
}
// ポイント送信サービス
//
// @params
// ctx コンテキスト
// toUserId 送付先ユーザーID
// sendPoint 送付ポイント
func (sp *SendPoint) SendPoint(ctx *gin.Context, toUserId, sendPoint int) error {
// コンテキストよりUserIDを取得
uid, _ := ctx.Get(auth.UserID)
fromUserID := uid.(model.UserID)
// トランザクション開始
if err := sp.Connection.Begin(ctx); err != nil {
return fmt.Errorf("cannot trasanction: %w ", err)
}
// 送付可能か残高を調べる
email, _ := ctx.Get(auth.Email)
stringMail := email.(string)
u, err := sp.UserRepo.FindUserByEmail(ctx, sp.DB, &stringMail)
if err != nil {
// エラーが起きたらロールバックする
if err := sp.Connection.Rollback(); err != nil {
return fmt.Errorf("cannot trasanction: %w ", err)
}
return err
}
sendablePoint := model.NewSendablePoint(u.SendingPoint)
if !sendablePoint.CanSendPoint(sendPoint) {
if err := sp.Connection.Rollback(); err != nil {
return fmt.Errorf("cannot trasanction: %w ", err)
}
return fmt.Errorf("can not send for not having sendable point: %w", repository.ErrHasNotSendablePoint)
}
// ポイント登録
if err := sp.PointRepo.RegisterPointTransaction(ctx, sp.Connection.Tx, fromUserID, model.UserID(toUserId), sendPoint); err != nil {
if err := sp.Connection.Rollback(); err != nil {
return fmt.Errorf("cannot trasanction: %w ", err)
}
return err
}
// 送信ユーザの送信可能ポイントを減らす
if err := sp.PointRepo.UpdateSendablePoint(ctx, sp.Connection.Tx, fromUserID, sendablePoint.CalculatePointBalance(sendPoint)); err != nil {
if err := sp.Connection.Rollback(); err != nil {
return fmt.Errorf("cannot trasanction: %w ", err)
}
return err
}
// トランザクション終了
if err := sp.Connection.Commit(); err != nil {
return fmt.Errorf("cannot trasanction: %w ", err)
}
return nil
}
まとめ
以上で、簡単にディレクトリ構成を紹介しました。
詳しいソースコードは、以下のリポジトリに実装しておりますので、見てみてください。
参考
Discussion