はじめに
はじめまして、先日、路頭に迷っていた子猫を保護して人生で初めて猫と生活を始めた、初級者エンジニアです。
依存関係の逆転?アーキテクチャパターン?って何・・・?みたいなところからスタートし、勉強のためにオニオンアーキテクチャ(的な)実装チャレンジをGo(Echo)でしてみたので、備忘録です。
オニオンアーキテクチャについて
オニオンアーキテクチャは、ソフトウェア開発において柔軟で維持しやすいアプリケーションを構築するための設計パターン。
このアーキテクチャの目的は、アプリケーションのコアを保護し、各層が次の層にのみ依存するようにすることで、結合を制御し、より保守性の高いアプリケーションを作ること。
※オニオンアーキテクチャを表す玉ねぎの画像(Jeffrey Palermoさんのサイトから拝借)
コアにあるドメイン層はどこにも依存していなく、外の層は内側の層に向かって依存している。
以下のような層で構成されている認識
- ドメイン層 (コア):この層はアーキテクチャの中心に位置する。ここにはビジネスロジックやビジネスルール、データの構造(エンティティ)が定義されており、他のどの層にも依存しない。
- アプリケーション層:ビジネス要件に基づき、具体的な処理やビジネスロジックを実行する。ドメインモデルで定義されたルールを利用して、実際のビジネスプロセスを処理する層。
- インフラストラクチャ層:データベースアクセスなど、外部サービスへの接続を直接行う層。
- プレゼンテーション(UI)層:ユーザーインターフェースを管理し、ユーザーからの入力を受け取り、適切なアクションを行う層。
- リポジトリ: データアクセスを中心に扱う。ここにデータアクセスのロジックを集約することで、他の層がデータアクセスの方法を気にせずビジネスロジックに集中できるようにする。
補足:リポジトリは、厳密にはアーキテクチャを構成する「層」自体を表すものではないはずなのですが、個人的に残しておきたかったので上に記載しました。
実装にチャレンジ
早速チャレンジしていきます!
今回は、製造者情報(IDと名前と削除フラグのみ)を取得してくる機能を実装しました。
ディレクトリ構成
.
├── domain
│ ├── model
│ │ └── manufacturer
│ │ └── manufacturer.go
│ └── repository
│ └── manufacturer.go
│
├── handler
│ ├── message
│ │ └── getManufacturer.go
│ └── manufacturerHandler.go
│
├── infrastrcuture
│ └── persistence
│ ├── dto
│ │ └── manufacturer.go
│ ├── manufacturerRepository.go
│ └──db.go
│
├── usecase
│ └── manufacturerUseCase.go
│
└── main.go
次に、コアであるドメイン層から順(依存していない順)にコードの内容を紹介します。
1. domain(ドメイン層)
2. usecase(アプリケーション層)
3. infrastructure(インフラ層)
4. handler(プレゼンテーション層)
5. その他
1. domain
エンティティをmodelファイルに記述します。
Manufacturerという型で製造者情報を表しています。Reconstruct関数ではバリデーションを行い、エンティティのインスタンスを正しく生成する役割を持っています。
また、getterを記述して、外からmodelのプロパティに直接アクセスしないようにします。
※このバリデーションは、データベースから取得したデータがビジネスルールと整合性が取れているかどうかをチェックしています。
※IDはULIDにしているのでstring型になります。
package manufacturer
import (
"fmt"
)
type Manufacturer struct {
id string
name string
isDeleted bool
}
func Reconstruct(id, name string, isDeleted bool) (*Manufacturer, error) {
if id == "" {
return nil, fmt.Errorf("IDが空です")
}
if name == "" {
return nil, fmt.Errorf("名前が空です")
}
if len(name) > 50 {
return nil, fmt.Errorf("名前が50文字を超えています")
}
return &Manufacturer{
id: id,
name: name,
isDeleted: isDeleted,
}, nil
}
func (m *Manufacturer) ID() string {
return m.id
}
func (m *Manufacturer) Name() string {
return m.name
}
func (m *Manufacturer) IsDeleted() bool {
return m.isDeleted
}
ドメインのインターフェースをrepositoryファイルに書きます。
インフラ層とユースケース層の橋渡し役。
package repository
import (
"app/domain/model/manufacturer"
)
type ManufacturerRepository interface {
GetManufacturers() (*[]manufacturer.Manufacturer, error)
}
2. usecase
この層では、インターフェースを通じてデータを取得しています。
具体的なデータアクセス(DBからどうやってデータを取得するか)は、インフラ層で行う。
また、後で紹介するインフラ層の「GetManufacturers()」メソッドでは、DTO(Data Transfer Object)で定義したオブジェクトを返り値にしているため、ここでmodelオブジェクトに変換させている。
package usecase
import (
"app/domain/model/manufacturer"
"app/domain/repository"
"net/http"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
)
type ManufacturerUseCase struct {
r repository.ManufacturerRepository
}
func NewManufacturerUseCase(r repository.ManufacturerRepository) ManufacturerUseCase {
return ManufacturerUseCase{r: r}
}
func (u *ManufacturerUseCase) GetManufacturers() (*[]manufacturer.Manufacturer, error) {
d, err := u.r.GetManufacturers()
if err != nil {
return nil, err
}
// DTOをドメインモデルのオブジェクトに変換する
result := make([]manufacturer.Manufacturer, len(*d))
for i, dto := range *d {
// 取得してきたデータのDeletedAtに値が入っていれば、削除フラグをtrueにする
isDeleted := dto.DeletedAt.Valid
_m, err := manufacturer.Reconstruct(dto.ID, dto.Name, isDeleted)
if err != nil {
return nil, err
}
result[i] = *_m
}
return &result, nil
}
インターフェースを挟むことで、将来インフラ層で、データ取得方法などが変わってもユースケース層は影響を受けないようになっている。(はず・・・)
3. infrastructure
この層では、データベース操作を行います。
DTO(Data Transfer Object)を定義しておくことで、ドメインオブジェクトをさらに依存関係から離すことができるということなので、これも書いてます。
まずは、DTOの定義
package dto
import (
"gorm.io/gorm"
)
type Manufacturer struct {
ID string
Name string
DeletedAt gorm.DeletedAt
}
次に、リポジトリファイルにデータベース操作の内容を書きます。
今回は製造者情報の全件取得です。
データベースから取得したデータは、DTOで定義した構造に変換されます。
package persistence
import (
"app/domain/model/manufacturer"
"app/infrastructure/persistence/dto"
"gorm.io/gorm"
)
type ManufacturerRepository struct {
db Db
}
func NewManufactureRepository(db Db) *ManufacturerRepository {
return &ManufacturerRepository{db: db}
}
func (r *ManufacturerRepository) GetManufacturers() (*[]dto.Manufacturer, error) {
db := r.db.ConnectDigitDb()
var manufacturers []dto.Manufacturer
result := db.Unscoped().Find(&manufacturers)
return &manufacturers, result.Error
}
以下のdb.goで、データベース設定や接続管理を行います。
package persistence
import (
"os"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type Db struct {
}
func NewDb() Db {
return Db{}
}
func (*Db) ConnectDigitDb() (*gorm.DB) {
USER := os.Getenv("TEST_USER")
PASS := os.Getenv("TEST_PASSWORD")
PROTOCOL := os.Getenv("TEST_PROTOCOL")
DBNAME := "TESTDB"
CONNECT := USER + ":" + PASS + "@" + PROTOCOL + "/" + DBNAME + "?charset=utf8&parseTime=true&loc=Asia%2FTokyo"
db, err := gorm.Open(mysql.Open(CONNECT), &gorm.Config{})
if err != nil {
panic("TESTDBとの接続に失敗しました")
}
db.Logger = db.Logger.LogMode(logger.Info)
return db
}
4. handler
この層では、クライアントからのリクエストの処理を担当します。
HTTPリクエストを受け取ったら、適切な処理を行った後、製造者情報をレスポンスとしてクライアントに返します。
また、データの取得をここからユースケースにお願いします。その返り値がmodelオブジェクトなので、クライアント用にmessageオブジェクトに変換します。
package handler
import (
"app/handler/message"
"app/usecase"
"net/http"
"github.com/labstack/echo/v4"
)
type ManufacturerHandler struct {
u usecase.ManufacturerUseCase
}
func NewManufacturerHandler(u usecase.ManufacturerUseCase) *ManufacturerHandler {
return &ManufacturerHandler{u: u}
}
func (h *ManufacturerHandler) GetManufacturers(c echo.Context) error {
result, err := h.u.GetManufacturers()
if err != nil {
return err
}
// ユースケースから取得してきたオブジェクトをmessageオブジェクトに変換する
res := make([]message.GetManufacturersResponse, len(*result))
for i, v := range *result {
res[i] = *message.ReconstructGetManufacturersResponse(v.ID(), v.Name(), v.IsDeleted())
}
return c.JSON(http.StatusOK, res)
}
次に、クライアントに返すデータ構造を定義し、実際にこの構造に変換するメソッドも記述します。
package message
import "strings"
type GetManufacturersResponse struct {
ID string `json:"id"`
Name string `json:"name"`
IsDeleted bool `json:"isDeleted"`
}
func ReconstructGetManufacturersResponse(id, name string, isDeleted bool) *GetManufacturersResponse {
return &GetManufacturersResponse{
ID: strings.ToLower(id),
Name: name,
IsDeleted: isDeleted,
}
}
続いて、ルーティングもハンドラ層で書きます。
また、依存性の注入もここで行います。
package handler
import (
"app/domain/model"
"app/infrastructure/persistence"
"app/usecase"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func Router(e *echo.Echo) {
// 依存性の注入
db := persistence.NewDb()
manufacturerPersistence := persistence.NewManufactureRepository(db)
manufacturerUsecase := usecase.NewManufacturerUseCase(manufacturerPersistence)
manufacturerHandler := handler.NewManufacturerHandler(manufacturerUsecase)
e.POST("/login", adminUserHandler.Login)
api := e.Group("/api")
api.GET("/manufacturers", manufacturerHandler.GetManufacturers)
}
5. その他
最後にmain.goを記述します。
諸々の説明は省きます。(ごめんなさい)
package main
import (
"app/handler"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(middleware.CORS())
handler.Router(e)
e.Logger.Fatal(e.Start(":8080"))
}
以上までが、製造者情報の取得をオニオンアーキテクチャで行った内容になります!🐈
まとめ
反省点というか、個人的に、気になる点がいくつかありました。
ユースケース内でインフラからもらったDeletedAtを、booleanに変換している記述があるけども、果たしてあそこで行うべきなのかというところなど
正解があるのかわかりませんが、これがもし大きなサービスだったりしたら影響あるのかもしれないのでしょうか・・・?
残りのCRUDも実装しながらその辺りにも思いを馳せようと思います。
アプリケーションの開発において、未来まで続く安全なサービスを作れるエンジニアになるためにも今後も引き続き精進していこうと思います!
最後まで読んでいただきありがとうございました!