LoginSignup
4
0

More than 1 year has passed since last update.

DDDとは?GOとは?って状態からAPIをつくってみた

Last updated at Posted at 2023-01-29

はじめに

Goは未経験。クリーンアーキテクチャは引き継いだプロジェクトで経験したけどよくわかっていない。(引き継いだクリーンアーキテクチャもちょっと読んだ記事とは違う実装をしている気がする。)
MVCはRuby on Railsや.net core MVCで経験あり。
最近、GoでAPIをDDDで開発するという話を耳にすることがあったので、興味を持って試しにやってみた。その時のメモと感想です。

DI

DDDに入る前にDIをある程度理解していないと挫折する・・・らしい。ので、DIを理解する。
DIを使うことのメリット。

  • 外部のDBに依存しないので、変更に強くなる
  • ユニットテストがしやすくなる
  • 単にコーディングがしやすくなる

そうなんだろうなぁと思う。ただ、チーム開発ではなく一人PJや少数で人の入れ替えのないPJなんかだとあまりメリットを想像できない。
インタフェースを使う側が実装オブジェクトを意識しないで実装できるのは確かだけど、一人PJだともちろん関係ない。実装オブジェクトを意識してる。
ユニットテストがしやすくなるは、その通り。・・・なんだけど、実際のPJでユニットテストのカバレージが高いPJ(システム理解してくれるPJ)にあたったことがない。
コーディングはしやすくなるのも確かなんだけど、チームの担当割りの問題なのか誰かが作ってるのを待ってるってことはそうそうない。
例えば、AさんがAPI A担当、BさんがAPI B担当ってことはある。Aさんの使うテーブルをBさんがjoinして使いたいって時は、担当決めのときにAさんがAPI AとBの両方担当とかにする。共通モジュールみたいなものが必要になったりすることももちろんあるけど、共通モジュール作るのに何日も必要になって、その完了を待つのに作業がとまるなんてことはそうそうない。あったらそれは、共通モジュールの設計を疑うべきでは?という考えで、メリットは理解できるけど、実感はほとんど得られたことがない。

否定してるわけではなくて、コーディング規約のように決まっていれば共通認識をもって変化に強く、コーディングしやすくもなると思う。
ダメなのがあっちはこうしてるのに、こっちはこうなんだ。そうしたことに意味があるのか?っていうのがコーディングした人にしかわからない。

Lintに抗わなければ物理的に(if文だったりfor文だったり)コードは統一されたものになるけど、論理的に統一されたものにはならない。
rubyだとrubocopに従えば、統一された記法になる。(と言うかいろんな書き方ができるrubyでコーディング規約みたいなのないとカオスになる。おかげで人のコード読むのがめっちゃ得意になった。好きではないけど、得意。)

メリットを理解するよりも、「論理的に統一されたプログラムとするためにDIでこのプロジェクトはやっていくので逸脱しないように」ってくらいでいいかも。
もちろん、きちんと理解した人がソースレビューして品質を担保する必要はある。物理的なソースはLintでやれるけど、論理的なところは人がやるしかないよね。

依存性の注入とか言葉に振り回されると迷路に入ってしまう・・・気がする。

と長くなったけど、ざっくりDIの実装を確認。ここではレイヤーについては後述するのでそんなものがあるんだなー程度で良い。

repository層

コンストラクタがdbを引数にとることで、dbオブジェクトに依存していない。
コンストラクタでinterfaceをreturn. (コンストラクタでreturnとか言ってる時点で気持ち悪いんだけど、今回は気にしない。)
これによりこれを利用するservice層は実装を知らない。(依存していない)。

package repository

type UserRepository interface {
    CreateUser(ctx context.Context, user *model.User)
}

type userRepository struct {
    db sql.DB
}

func NewUserRepository(db *sql.DB) UserRepository {
    return &userRepository{*db}
}

func (ur *userRepository) CreateUser(ctx context.Context, user *model.User) {
    ur.db.Query("INSERT INTO テーブル名(列名1,列名2,……)")
}

service層(Controller層とDomainModel層、DataAccess層を仲介する層)

UserRepositoryインタフェースを引数にとっているのでリポジトリに依存していない。
コンストラクタでインタフェースをreturnしているのでこれを使う側も依存しない。

package service

type UserService interface {
    CreateUser(ctx context.Context, user *model.User)
}

type userService struct {
    ur repository.UserRepository
}

func NewUserService(ur repository.UserRepository) UserService {
    return &userService{ur}
}

func (us *userService) CreateUser(ctx context.Context, user *model.User) {
    us.ur.CreateUser(ctx, user)
}

controllerとか、Usecaseとかからの呼び出し

利用する側でDBやらなんやら指定する(依存性を注入)。
ここでは、mysqlを使ってるけど、こっちではpostgreつかってるとかになっても、注入を変えてやるだけで同じように使えるぜ。
それは、interfaceを介してるから。って理解で良いかな。

package main

func main() {
    ur := repository.NewUserRepository(config.NewDB())
    us := service.NewUserService(ur)
}

package config

import (
	"go/domain/model"

	"github.com/jinzhu/gorm"
)

// NewDB DBと接続する
func NewDB() *gorm.DB {
	db, err := gorm.Open("mysql", "root:password@tcp(127.0.0.1:3306)/test?charset=utf8&parseTime=True&loc=Local")
	if err != nil {
		panic(err)
	}

	return db
}

こんな感じで、interfaceを経由しようね。コンストラクタインジェクションでやろうね。他にもインジェクションの方法はあるけど、
コンストラクタインジェクションで都合が悪いときに考えようね。くらいでいい気がする・・・。

・・・ホントに理解してる人からめっちゃ怒られそうだけど、最初はこのくらいの理解でやってみる。

レイヤードアーキテクチャ

各層ごとに責務を切り分け、依存の方向を一方向にするようなアーキテクチャ
interface → usecase → domain → infrastracture

DDDでは、DIP(依存関係逆転の原則)を用いてinfrastructureがdomainに依存する

interface → usecase → domain ← infrastracture

と言われても実際のプログラミングではどういうことなのかは概念だけではわからない。とりあえず、一番理解しやすかったプログラムから理解を試みたけど、良さがいまいちわからない。MVCでもいいかなぁ。実際運用してみて変化に強いことを実感しないと良さは分からないと思った。

ちなみに、クリーンアーキテクチャのプロジェクトを引き継いだことがあるが、変化に強いとは思えなかった。そのプロジェクトがなんちゃってクリーンアーキテクチャだったからなんだけど。
いろんなところで言われてる通り、クリーンアーキテクチャにするとコーディング量が増える。
変化に強いかもしれないけど、変化させるのにコーディング量が多くなって時間がかかる。これ何のためにやってんだ?ってなったなんちゃってクリーンアーキテクチャでした。

いまだよくわからないのが、Usecase層。単純なプログラムだとファイルが増えて、コーディング量が増えてこの存在意義は?と思ってしまう。わかりやすくこの疑問に回答してくれてるところが見つからなかったんだけど、Usecase難民になってる人は少なからずいるらしい。

それらを踏まえて、各レイヤーを確認してみる。

domain

  • アーキテクチャの中心となるもっとも重要な層
  • 業務の関心事が集まっている
  • ドメインのルールやデータの加工などのビジネスロジックが置かれる
  • domain層には技術的関心事を実装してはいけない
    技術的関心事というのは「DBにMySQLを使って」や「ORMを使って」など。
    これによりdomain層が特定の技術に依存しなくなる。
  • システム都合ではないコアなルールをドメインロジックにする。

domain層はmodelとrepositoryに分ける

model

  • modelの構造体やビジネスロジックを定義する

repository

  • DBのやり取りを定義する。ただし、技術的関心事を記載してはいけない。
    つまり、各メソッドをinterfaceとして定義しておいて、実装が書いてあるinfrastructure層がこのdomain層に依存するように実装する
    これが、依存関係逆転の原則である。

infrastructure

  • 技術的基盤へのアクセスを提供する層。
  • DBの操作をしたり、メールを送信するために外部のメール送信サービスとやり取りをしたりする。
  • repositoryに定義したinterfaceに合わせて、中身のメソッドを実装する

usecase (application serviceとも呼ばれる)

  • interface層から受け取ったリクエストをもとにデータの参照や保存、削除などを制御する
  • システムにしたことによって発生するロジックをユースケースに実装
  • applicationと呼ばれることもある
  • ユースケースに沿った処理の流れを実装する
  • データの取得や保存などでDBにアクセスするときもdomain層のrepositoryを介してアクセスすることによって、infrastructure層ではなくdomain層のみに依存するように実装すること

interface (User Interfaceやプレゼンテーション層とも呼ばれる)

  • ユーザーからのリクエストを受け取ったり、usecase層からのレスポンスをユーザーに返す層。
  • usecase層と切り離すことでリクエストやレスポンスがどんな形に変わってもここの修正だけで済むようになる。

この意味は分かるんだけど、interface層からdomain層を呼ぶのはダメなの?ようは、interface層がusecase層も兼ねる。MVCでいうところのfat controllerになるということが問題?

いろんなサンプルを見てて思ったのが、DB操作を基本的に単純にしている。誤解を恐れずに言うなら、ワンクエリーでできるようにシンプルに実装している。Update文もUpdate文を書くのではなく、rebositoryで取得したobjectを更新して、objectをsaveするみたいな実装が多く、SQLというよりもobjectを操作している感じ。
PJのルールによるんだろうけど、SQLをガリガリ書いてきたエンジニアからすると、UPDATE文書くほうが速いしわかりやすいと思う。
こう言ったobjectの操作をUsecaseで行う。joinもSQLで行うのではなく、Usecaseでobjectを関連付けてたりするのも見かけた。
・・・プログラムはシンプルになると思うけど、処理に時間がかかる。To C 向けのサービスでレスポンスが1秒速くなるだけで月の売り上げが100万変わってくるとかって言われるサイトだと検索速度よりもシンプルなプログラムが優先されるってことはあり得ない。コーディング規約重要 ってことかな。この件の解決策は、CQRSパターンと言うのがあるらしいので後述します。

話は戻って、実際に簡単なプログラムを作って、現実的にあり得そうなものを作ってに追加機能として追加していってみる。(変化に対応していってみる)

ユーザー管理プログラムを作ってみる

APIをなぜかGo指定で作って欲しいという依頼があったとして、NBA選手の登録、削除、検索、更新ができればいい。みたいなざっくりとした要件。
将来的に機能追加をお願いするかもしれないが、とりあえずは、名前、ポジション、チームを登録できて、登録IDで詳細を検索、削除、更新ができればいい。フロントエンドは別チームが作ってるからとりあえず、サーバー側としてAPIのサンプルを作って。みたいなこと・・・あるかな?

DDDでは業務ロジックをそのままコードに落とすわけなんだけど、DB脳になってしまってると、テーブル → オブジェクトと頭の中で設計が出来上がっていく。
これは良いのか?とりあえず、モデルから作ってみる。

ソースツリーはこんな感じ。

go_sample
│
│
├─api
│      main.go
│
├─config
│      database.go
│
├─domain
│  ├─model
│  │      player.go
│  │
│  └─repository
│          player.go
│
├─infra
│      player.go
│
├─interface
│  └─handler
│          player.go
│          router.go
│
└─usecase
        player.go

これも好みがあると思う。ファイル名とclassは同じにするとか、1ファイル1classにすべきとか。こういうのもPJで決めてソースレビューでチェックしていけばよい。
個人的な好みは、

  • interfaceはIをつける
  • ディレクトリにinterfaceとつけるならファイル名にinterfaceとつけない
    クリーンアーキテクチャを引き継いだプロジェクトでディレクトリにinterfaceとついてたり、impleってついてるのに、ファイル名にもinterfaceとかimpleってついてたのがトラウマになってる。冗長。
  • interfaceと実装は同じファイルに記述する
  • 1ファイル1classにこだわらない
  • ファイル名とclass名を一致させることにこだわらない
    これは完全に少数派だと思う。けど、interfaceから実装を確認したいときにファイルが分かれてるとめんどくさい。returnがinterfaceになってるとvscodeで定義に移動すると実装を確認したいときにファイルを開かないといけないのがめんどくさい。ってのが大きな理由。

好みはさておき、以下ルールとした。

  • interfaceはIをつける
  • ファイル名とファイルの中の名前(構造体名だったりメソッド名だったり)を一致させることにこだわらない
  • ディレクトリ名はなるべくレイヤー名とする
  • ファイル名は・・・悩ましい

Domain層

ドメイン層はmodelとrepositoryに分ける

model

modelには構造体やビジネスロジックを定義する。構造体はいいとして、コンストラクは完全コンストラクにするらしい。
「コンストラクタで全てのプロパティの値が確定し、そこから変化しないこと」 がこの完全コンストラクパターンで、オブジェクトを不変(Immutable) にすることで安全性が高まるということです。
ですが、golangの場合、変更できてしまうのでここは、コーディング規約のようなプロジェクト内での取り決めが必要。
コンストラクタと言ってみたが、構造体なので実際はコンストラクタではなく、メソッドでプロジェクト内でこうしようという取り決めが必要。
よくあるのが、New+構造体。ホントにコンストラクタだと思ってるとreturnと書かれてることに戸惑いgo難しいとなる・・・。

c# なんかだとデフォルトのコンストラクをprivateで定義して使わせないようにしたりとか、プロパティなんかもprivateで定義したりお作法としてやってました。10年以上前の話なので今の書き方は違うのかも。それはさておき、goにはコンストラクタがないのでデフォルトのコンストラクタを気にしなくても良い。

golangであれ、c#であれプロジェクト内でのルールは必要で、そのルールを守っているかを確認するのがソースレビューって位置づけかな。
つまりは、ソースレビューでプログラムのバグを見付けるのじゃなく(見つかれば尚いいけど)Lintで引っかからない論理的なルール違反(バグ)を見つけるのが目的。
完全コンストラクタになってるか(privateで宣言してるか)とかを確認する。おかしな使い方をしてるとコンパイル時にエラーが発見できるのが静的型付け言語の良いところ。

package model

import (
	"errors"
)

// Playerの構造体
type Player struct {
	ID        int
	FirstName string
	LastName  string
	Position  string
	Team      string
}

// コンストラクタの戻りはポインタにするのが一般的
func NewPlayer(firstName, lastName, position, team string) (*Player, error) {
	if firstName == "" {
		return nil, errors.New("First Nameを入力してください")
	}
	if lastName == "" {
		return nil, errors.New("Last Nameを入力してください")
	}
	if position == "" {
		return nil, errors.New("Positionを入力してください")
	}
	if team == "" {
		return nil, errors.New("Teamを入力してください")
	}

	player := &Player{
		FirstName: firstName,
		LastName:  lastName,
		Position:  position,
		Team:      team,
	}

	return player, nil
}

repository

domain層には技術的関心事を実装してはいけないというルールがあるので、domain層のrepositoryはinterfaceのみを定義します。
infrastructure層でこのinterfaceを実装して、infrastructure層がdomain層に依存するようにしてあげる。
・・・と書いてみたが、依存するようにしてあげるがイマイチわからない。そんな時は、とりあえず、そういうコーディングルールなんだと思うようにする。

一般的にSQLを書くイメージではなく、オブジェクトを操作する実装になるようなので、それに倣うと以下のようになる。

package repository

import (
	"go/domain/model"
)

// Player repositoryのinterface
type IPlayerRepository interface {
	Create(player *model.Player) (*model.Player, error)
	FindByID(id int) (*model.Player, error)
	Update(player *model.Player) (*model.Player, error)
	Delete(player *model.Player) error
}

個人的には、DeleteはDeleteByID(id id) errorとしてもいいかなと思う。Updateに関しても思うところがあり後述。
これもPJのきめの問題かなと思う。

infrastructure層

repositoryに定義したinterfaceに合わせて中身を実装します。
domain層に定義、infrastructure層に実装とすることで変更があってもこの層だけを変更すればよいことになる。

package infra

import (
	"go/domain/model"
	"go/domain/repository"

	"github.com/jinzhu/gorm"
)

// player repositoryの構造体
type PlayerRepository struct {
	Conn *gorm.DB
}

// returnはinterfaceとすること
func NewPlayerRepository(conn *gorm.DB) repository.IPlayerRepository {
	return &PlayerRepository{Conn: conn}
}

// playerの新規作成
// この書き方に慣れないのでコメントを残す
// PlayerRepositoryのstructにCreateというメソッドを実装し、引数にmodel.Playerをポインタで受け取り、model.Playerのポインタを返す
func (repository *PlayerRepository) Create(player *model.Player) (*model.Player, error) {
	// ↓のif文がポインタを理解するのにいいかも。playerをポインタで渡すことで、参照渡しとなって、returnで受け取らなくてもplayerは書き換わってる(idが入ってくる)
	if err := repository.Conn.Create(&player).Error; err != nil {
		return nil, err
	}

	return player, nil
}

// playerをIDで取得
func (repository *PlayerRepository) FindByID(id int) (*model.Player, error) {
	player := &model.Player{ID: id}

	if err := repository.Conn.First(&player).Error; err != nil {
		return nil, err
	}

	return player, nil
}

// playerの更新
func (repository *PlayerRepository) Update(player *model.Player) (*model.Player, error) {
	if err := repository.Conn.Model(&player).Update(&player).Error; err != nil {
		return nil, err
	}

	return player, nil
}

// playerの削除
func (repository *PlayerRepository) Delete(player *model.Player) error {
	if err := repository.Conn.Delete(&player).Error; err != nil {
		return err
	}

	return nil
}

一般的な書き方だと上記のようになるみたいだけど、SQLが得意な人には下記のほうが、ソースの可読性は良いかも。
と言うのと、gormのdeleteは、

レコードを削除する際、主キーが値を持っているかを確認してください。GORMはレコードを削除する際に主キーを使うので、主キーが空の場合、GORMはそのモデルの全レコードを削除してしまいます。

とのことで、主キーが0の構造体だったり、空のスライスを渡すと全件削除になるらしい・・・。やっぱり明示的に指定するほうが良いんじゃないかな。

Ruby on Railsのactive recordもイメージしてたSQLを発行してくれないときがあったりN +1問題を起こしたりでいい思い出がない。

SQLに慣れてるエンジニアが多いときは、Micro-ORMの利用を検討するのが良い。C#で使われるDapperみたいなのが他の言語でもあるのかは知らないですが・・・。
10年以上前とかだと、DB担当者にクエリを書いてもらったり、ストアドにしてプログラマはストアドを呼ぶだけにしたりってプロジェクトもあったけど、今はORMでやっちゃうからSQLは簡単なのが書ければいい・・・って感じなのかな・・・。

// playerの削除
func (repository *PlayerRepository) Delete(id int) error {
	if err := repository.Conn.Where("id = ?", id).Delete(&player{}).Error; err != nil {
		return err
	}

	return nil
}

※update文については後述

usecase層

ユースケースに沿った処理の流れを記述する。DBにアクセスするときはdomain層のrepositoryにのみアクセスすることでdomain層にのみ依存するようにする。
以下プログラムを作っていて、interfaceがドメイン層のrepositoryとほとんど変わらず、実装もrepositoryのメソッドをinterfaceを介して実行しているだけだったりする。
ここでいつも悩み、usecase難民となる。

ユースケースに沿った処理の流れを記述する。DBにアクセスするときはdomain層のrepositoryにのみアクセスすることでdomain層にのみ依存するようにする。
以下プログラムを作っていて、interfaceがドメイン層のrepositoryとほとんど変わらず、実装もrepositoryのメソッドをinterfaceを介して実行しているだけだったりする。
ここでいつも悩み、usecase難民となる。

Hello worldてきなAPIを作っているとusecase難民になったが、複数のテーブル(オブジェクト)を操作するようなことを実装するとUsecaseが太ってきた。
以前経験したプロジェクトでは、domain層やinterface層に散らかっていたことをusecase層でやればわりとすっきりする。
何をどこで処理するかは結局センスが問われるなぁと思う。

ここでは以下としようと思う

  • usecaseから別のusecaseを呼び出すということはしない
  • トランザクション処理はusecase層で行う
  • ユースケースは以下を採用します

一般的なユースケース

通常ユースケースは日本語的には「[主語(=Actor)]が[動詞]する」という形式で表現されます。

例えば掲示板的なシステムの場合は

一般ユーザが投稿一覧を見る
ゲストユーザ(非ログインユーザ)が投稿一覧を見る
一般ユーザが投稿する
などがユースケースになります。

主語がclass名で、動詞がメソッドってことで良いのかな。

package usecase

import (
	"go/domain/model"
	"go/domain/repository"
)

// player usecaseのinterface
type IPlayerUsecase interface {
	Create(firstName, lastName, position, team string) (*model.Player, error)
	FindByID(id int) (*model.Player, error)
	Update(id int, firstName, lastName, position, team string) (*model.Player, error)
	Delete(id int) error
}

type playerUsecase struct {
	playerRepo repository.IPlayerRepository
}

// player usecaseのコンストラクタ
func NewPlayerUsecase(playerRepo repository.IPlayerRepository) IPlayerUsecase {
	return &playerUsecase{playerRepo: playerRepo}
}

// Create playerを保存するときのユースケース
func (usecase *playerUsecase) Create(firstName, lastName, position, team string) (*model.Player, error) {
	// package modelのNewPlayerメソッドを実行と言う意味で、modelインスタンスのNewPlayerメソッドと言うことではない。
	player, err := model.NewPlayer(firstName, lastName, position, team)
	if err != nil {
		return nil, err
	}

	createdPlayer, err := usecase.playerRepo.Create(player)
	if err != nil {
		return nil, err
	}

	return createdPlayer, nil
}

// FindByID playerをIDで取得するときのユースケース
func (usecase *playerUsecase) FindByID(id int) (*model.Player, error) {
	player, err := usecase.playerRepo.FindByID(id)
	if err != nil {
		return nil, err
	}

	return player, nil
}

// playerを更新するときのユースケース
func (usecase *playerUsecase) Update(id int, firstName, lastName, position, team string) (*model.Player, error) {
	player, err := usecase.playerRepo.FindByID(id)
	if err != nil {
		return nil, err
	}

	player.FirstName = firstName
	player.LastName = lastName
	player.Position = position
	player.Team = team

	updatedPlayer, err := usecase.playerRepo.Update(player)
	if err != nil {
		return nil, err
	}

	return updatedPlayer, nil
}

// Delete playerを削除するときのユースケース
func (usecase *playerUsecase) Delete(id int) error {
	player, err := usecase.playerRepo.FindByID(id)
	if err != nil {
		return err
	}

	err = usecase.playerRepo.Delete(player)
	if err != nil {
		return err
	}

	return nil
}

infrastracture層で考えた通り、Deleteの実装をオブジェクトを削除するイメージから、SQL文で指定IDのレコードを削除するイメージにした場合、usecaseで、FindByIDでオブジェクトを取得して、そのオブジェクトをDeleteするなんてことをしなくてよい。
そうなると、usecaseは受け取ったパラメータをそのままドメイン層に渡して、返ってきたものをそのままinterface層に返す。ってことになる。
これって、必要なくない?interface層から直接domain層呼べばよくない?となる。

UpdateもDeleteと基本的には同じことで悩むんだけど、上記の実装だと、登録項目(FirstName, LastName, Position, Team)の入力チェック、完全コンストラクタが使われていない。と言うことで、Setterを用意してあげると良いのかなとも思ったけど、Deleteと同じでFindByIDしてupdateする必要はないんじゃないかと思い、引数違いのコンストラクタを作ればいいんじゃないの?
それで、調べたら、Goではオーバーロードができない。それなら、デフォルトパラメータ指定すればいいか?と思ったら、デフォルトパラメータの指定もできず、Functional Option Patternを使うらしい。どんどんコード量も増えるし、コーディング規約(論理的なチェック項目が増える)も増えるなぁ。
結局、好みの実装を探してください。という結論に・・・。
Goではコンストラクタはなくて、New + struct名でコンストラクタにしようとコーディング規約ができたなら、上記問題を解決するルールがあってもいいのかな・・・。

interface層

リクエスト、レスポンスを取り扱う層で、usecase層と切り離すことでリクエストやレスポンスがどんな形に変わってもここの修正だけで済むようになる。
今回はHTTPリクエストを受け取り、レスポンスをjsonとして返すようにする。
だけど、usecase難民はここからdomain層にアクセスしたい。

package handler

import (
	"net/http"
	"strconv"

	"go/usecase"

	"github.com/labstack/echo"
)

// Player handlerのinterface
type IPlayerHandler interface {
	Post() echo.HandlerFunc
	Get() echo.HandlerFunc
	Put() echo.HandlerFunc
	Delete() echo.HandlerFunc
}

type playerHandler struct {
	playerUsecase usecase.IPlayerUsecase
}

// player handlerのコンストラクタ
func NewPlayerHandler(playerUsecase usecase.IPlayerUsecase) IPlayerHandler {
	return &playerHandler{playerUsecase: playerUsecase}
}

// goの書式とjsonの書式の違いを吸収するためここで定義する
// 外部から参照されないように小文字で宣言
type requestPlayer struct {
	FirstName string `json:"first_name"`
	LastName  string `json:"last_name"`
	Position  string `json:"position"`
	Team      string `json:"team"`
}

// 外部から参照されないように小文字で宣言
type responsePlayer struct {
	ID        int    `json:"id"`
	FirstName string `json:"first_name"`
	LastName  string `json:"last_name"`
	Position  string `json:"position"`
	Team      string `json:"team"`
}

// Post playerを保存するときのハンドラー
// funcをreturnしているので一瞬戸惑う javascriptでやるあれか。
func (handler *playerHandler) Post() echo.HandlerFunc {
	return func(context echo.Context) error {
		var req requestPlayer
		if err := context.Bind(&req); err != nil {
			return context.JSON(http.StatusBadRequest, err.Error())
		}

		player, err := handler.playerUsecase.Create(req.FirstName, req.LastName, req.Position, req.Team)
		if err != nil {
			return context.JSON(http.StatusBadRequest, err.Error())
		}

		res := responsePlayer{
			ID:        player.ID,
			FirstName: player.FirstName,
			LastName:  player.LastName,
			Position:  player.Position,
			Team:      player.Team,
		}
		return context.JSON(http.StatusCreated, res)
	}
}

// playerを取得するときのハンドラー
func (handler *playerHandler) Get() echo.HandlerFunc {
	return func(context echo.Context) error {
		id, err := strconv.Atoi((context.Param("id")))
		if err != nil {
			return context.JSON(http.StatusBadRequest, err.Error())
		}

		player, err := handler.playerUsecase.FindByID(id)
		if err != nil {
			return context.JSON(http.StatusBadRequest, err.Error())
		}

		res := responsePlayer{
			ID:        player.ID,
			FirstName: player.FirstName,
			LastName:  player.LastName,
			Position:  player.Position,
			Team:      player.Team,
		}
		return context.JSON(http.StatusOK, res)
	}
}

// playerを更新するときのハンドラー
func (handler *playerHandler) Put() echo.HandlerFunc {
	return func(context echo.Context) error {
		// Atoi関数は、引数に指定された文字列を数値型に変換して返します。
		id, err := strconv.Atoi(context.Param("id"))
		if err != nil {
			return context.JSON(http.StatusBadRequest, err.Error())
		}

		var req requestPlayer
		if err := context.Bind(&req); err != nil {
			return context.JSON(http.StatusBadRequest, err.Error())
		}

		player, err := handler.playerUsecase.Update(id, req.FirstName, req.LastName, req.Position, req.Team)
		if err != nil {
			return context.JSON(http.StatusBadRequest, err.Error())
		}

		res := responsePlayer{
			ID:        player.ID,
			FirstName: player.FirstName,
			LastName:  player.LastName,
			Position:  player.Position,
			Team:      player.Team,
		}
		return context.JSON(http.StatusOK, res)
	}
}

// playerを削除するときのハンドラー
func (handler *playerHandler) Delete() echo.HandlerFunc {
	return func(context echo.Context) error {
		id, err := strconv.Atoi(context.Param("id"))
		if err != nil {
			return context.JSON(http.StatusBadRequest, err.Error())
		}

		err = handler.playerUsecase.Delete(id)
		if err != nil {
			return context.JSON(http.StatusBadRequest, err.Error())
		}

		return context.NoContent(http.StatusNoContent)
	}
}

mainとrouter作成

echo とかrouterとかもちゃんと調べたいけどいったん単純に記述。

package main

import (
	"go/config"
	"go/infra"
	"go/interface/handler"
	"go/usecase"

	_ "github.com/jinzhu/gorm/dialects/mysql"
	"github.com/labstack/echo"
)

func main() {
	// DI
	playerRepository := infra.NewPlayerRepository(config.NewDB())
	playerUsecase := usecase.NewPlayerUsecase(playerRepository)
	playerHandler := handler.NewPlayerHandler(playerUsecase)

	e := echo.New()
	handler.InitRouting(e, playerHandler)
	e.Logger.Fatal(e.Start(":3000"))
}

package handler

import (
	"github.com/labstack/echo"
)

// InitRouting routesの初期化
func InitRouting(e *echo.Echo, playerHandler IPlayerHandler) {

	e.POST("/player", playerHandler.Post())
	e.GET("/player/:id", playerHandler.Get())
	e.PUT("/player/:id", playerHandler.Put())
	e.DELETE("/player/:id", playerHandler.Delete())

}

私の好み

ここからは好みによって変更を加える

  • updateやdeleteはID指定でワンクエリーで実行する(selectを先に実行しない)
  • オーバーロードやデフォルトパラメータがつかえないけど、そもそもコンストラクタもルールでメソッドを利用してるだけなんだから、別名でコンストラクタをつくってしまう。

これで実装すると

type IPlayerRepository interface {
	Create(player *model.Player) (*model.Player, error)
	FindByID(id int) (*model.Player, error)
	Update(player *model.Player) (*model.Player, error)
	Delete(id int) error
}

// playerの削除
func (repository *PlayerRepository) Delete(id int) error {
	if err := repository.Conn.Where("id = ?", id).Delete(&model.Player{}).Error; err != nil {
		return err
	}

	return nil
}

// playerを更新するときのユースケース
func (usecase *playerUsecase) Update(id int, firstName, lastName, position, team string) (*model.Player, error) {
	// findByIdでdbアクセスするのではなく、インスタンス作成
	player, err := model.NewExistingPlayer(id, firstName, lastName, position, team)
	if err != nil {
		return nil, err
	}

	// オブジェクトを更新。今回はこれでも動作するか検証したが、Updateのインフラ層もDeleteと同じような実装にすると思う。
	updatedPlayer, err := usecase.playerRepo.Update(player)
	if err != nil {
		return nil, err
	}

	return updatedPlayer, nil
}

// playerを削除するときのユースケース
func (usecase *playerUsecase) Delete(id int) error {
	return usecase.playerRepo.Delete(id)
}

// コンストラクタの戻りはポインタにするのが一般的
func NewPlayer(firstName, lastName, position, team string) (*Player, error) {
	if firstName == "" {
		return nil, errors.New("First Nameを入力してください")
	}
	if lastName == "" {
		return nil, errors.New("Last Nameを入力してください")
	}
	if position == "" {
		return nil, errors.New("Positionを入力してください")
	}
	if team == "" {
		return nil, errors.New("Teamを入力してください")
	}

	player := &Player{
		FirstName: firstName,
		LastName:  lastName,
		Position:  position,
		Team:      team,
	}

	return player, nil
}

// コンストラクタの戻りはポインタにするのが一般的
func NewExistingPlayer(id int, firstName, lastName, position, team string) (*Player, error) {
	if id <= 0 {
		return nil, errors.New("正しいIDを入力してください")
	}

	player, err := NewPlayer(firstName, lastName, position, team)
	if err != nil {
		return nil, err
	}

	// IDを設定して返却
	player.ID = id

	return player, nil
}

みたいな感じかな。もちろんエラー処理とかパラメータチェックとかいろいろあるんだけど、わかりやすくするためいろいろ端折った。
結局、usecase難民なまま。テーブルをjoinしたりとか場合によってはフィルターなんかもwhereで書かずにusecaseでフィルターしてるサンプルもみた。
それは、個人的にはinfrastracture層(sql)でやりたいなぁと。
そこで調べてたら出てきたのが、CQRSパターン。これでもUsecase難民が解決されるわけではないと思うけど、ごちゃごちゃしたものが多少すっきりするかな。

CQRSパターン

CQRSは、Command Query Responsibility Segregationと呼ばれ、日本語ではコマンドクエリ責任分離と言うらしい。最初難しいものなのかなぁとか概念的なもので理解するのが難しいのかなぁと思ったけど、チラッと読んだだけであーそのことをCQRSって呼ぶのね。って理解できた。・・・本質をつかめてるかはわからないけど。

誤解を恐れずに言うと、例えば、記事系のサイトを運用していたとして、バックエンドのCMSとフロントエンドのウェブサイトが存在します。そしてその二つをつなぐのがDBです。CMSの主な役割は記事の投稿(insert)、編集(update)で、ウェブサイトの主な役割は記事の検索(search)、閲覧(read)です。
大規模サイトの場合、ウェブサイトの検索、閲覧(描画)に時間がかかるとユーザーに逃げられてしまいます。広告収入が得られなくなります。と言うことで、昔からよく使われるのが、登録用の正規化されたテーブルを持つDBと非正規化された検索に適したテーブルを持つDBの2種類用意することです。
CMSからの登録は正規化されたテーブルを持つDBに登録、ウェブサイトは、非正規化されたテーブルを持つDBを参照。この2種類のDB間はレプリケーションであったり、CMSの登録と同時に書き込んだりと方法は様々。

話はそれましたが、上記は、データソースの分離と呼ぶらしく、CQRSとは異なります。
それぞれが別々の問題を解決するのですが、考え方は似ているので、ざっくりとプログラムレベルで更新と参照を分けてしまおうというのがCQRSパターンと理解して進めてみることにする。

いくつか記事を読んでみたけれど、やっぱり小難しい単語が出てきてイマイチわからない・・・そんな時は、実践してみるのが良い。そして、良さを実感できなくてもそれがこのPJのコーディングルールなんだと思ってやり続けてみる。そうするといつか、これって便利だったんだなぁと気づく時が来る!・・・はず。
間違ったことをやって、使えねー、余計に複雑ってリスクもあるけれども・・・。

DDDでの問題点のおさらい

ところで、DDDで何に困ってたの?と言うところを簡単におさらい。データを永続化っていうらしいけど、ここではDBに登録と言ってしまう。
DBに登録するのはrepositoryを介してinfrastracture層で行ってきた。この登録は、テーブル毎にオブジェクトにするとメンテナンス性が良いものとなる。
一方で参照(select)するときは、joinが必要になることはある。このjoinに該当する処理をUsecaseにてプログラムで関連付ける。と言うサンプルをよく見た。

  • 複数のデータをループを回しながら、オブジェクトにデータを詰めるのは、プログラムが読みづらい
  • DBのクエリーはシンプルで速いかもしれないが、ループで結合するので結局、SQLでjoinするほうが速い。ということがある。

個人的には、MVCのfat modelを解消して、fat usecaseになってるだけなんじゃ・・・と思えてしまう。どこを太らせるかの違い。

CQRSで解決

CQRSは「情報の参照に使用するモデルと更新に使用するモデルに異なるものを使用する」と言うことで、更新はRepositoryを介してinfrastracture層で更新し、参照はQueryServiceという専用のオブジェクトを介してinfrastracture層で取得する。

  • 参照用モデルは1ユースケースに特化するのが基本
  • ドメイン知識を持たないのでドメイン層ではなくユースケース層に書く

参照モデルに最大の項目を持たせて、使いまわしたりしない。ユースケースごとに参照モデルを作成する。
interfaceはユースケース層に持たせる。業務知識を持たないから。
と言うことらしい。正直ここは正しく理解できていない。
業務知識を持たない?そうだろうか?システムにしたことによって発生するロジックをユースケースに実装に該当して、オブジェクトを分けたことによってそれを結合するのがUsecase層。ってことで、Usecase層でループ回しながら結合してきた。
だけど、今度は、オブジェクトをわけないから結合処理が必要ない。つまりは、業務通りでシステムによって発生するロジックは無いように思う。

何が正解かわからなくなってきたけど、Domain層のrepository interfaceの代わりにQueryService interfaceをつくって、infrastracture層でQueryServiceを実装する。ってことでいいんじゃないだろうか?モデルはDomain層に必要なモデルを作成する。ただし、使いまわさない。

これで何か不都合が出た時に再検討したい。もうちょっと勉強が必要。

実装

将来的に機能追加をお願いするかもしれないが、とりあえずは、名前、ポジション、チームを登録できて、登録IDで詳細を検索、削除、更新ができればいい。

早速だけ機能追加の依頼。最近のNBAではポジションレスって言われてて、複数のポジションにつくでしょ。ドレイモンド・グリーンはPFなのかCなのかわからないからとりあえず、複数のポジション登録できるようにして、詳細検索のAPIも登録されてるポジション全部返すようにしてよ。
(現実的な話に戻すと一社員一部署にしてたけど兼務もあり得ることになったから、よろしくー。みたいなこと。)

この場合、カラムにカンマ区切りとかjsonとかxmlで持たせるのも悪くはない。けど、CQRSを体験することが目的なので、テーブルを分けることにする。
一対多(player 対 position)のテーブルでやってみる。(ER図はいつか気が向いたらやります)
プロダクション環境で動いてる場合、互換性を保つためにめんどくさい作業が必要なんだけど、今回は、サンプルと言うことでめんどくさい作業はしません。

とりあえず、前回と同じ手順で作ってみます。

この対応をするとUsecase層でやることが増えてきて、Usecase難民ではなくなりました。だけど、ここでそれを記載すると長くなりすぎるので割愛します。

domain層

queryserviceにinterface、modelにstructを作成。

package queryservice

import (
	"go/domain/model"
)

type IPlayerQueryService interface {
	FindByID(id int) (*model.PlayerDisplay, error)
}

命名にいつも悩む・・・変な名前つけてごめん。Playerモデルを使うべきかと思ったけど、必要な参照モデルを作成するほうが良いと書かれていたのでいったんそうする。
(意味を読み違えてるかも)

package model

type PlayerDisplay struct {
	ID        int
	FirstName string
	LastName  string
	Team      string
	Position  []string
}

登録処理は今回まだ作らないのでコンストラクタはなし。

package model

import (
	"errors"
)

// Positionの構造体
type Position struct {
	ID       int
	PlayerID int
	Name     string
}

infrastructure層

queryserviceの実装部分。
今回は目的がワンクエリーで取得することだったので、Gorm APIで書いてみる。
・・・が、中途半端にこれで書くくらいならRawを使ってSQLを書くほうが良いと激しく後悔する。

package infra

import (
	"go/domain/model"
	"go/domain/queryservice"

	"github.com/jinzhu/gorm"
)

type PlayerDisplayQueryService struct {
	Conn *gorm.DB
}

// returnはinterfaceとすること
func NewPlayerDisplayQueryService(conn *gorm.DB) queryservice.IPlayerQueryService {
	return &PlayerDisplayQueryService{Conn: conn}
}

// playerをIDで取得
func (queryService *PlayerDisplayQueryService) FindByID(id int) (*model.PlayerDisplay, error) {
	query := queryService.Conn.Table("players").
		Select("players.*, positions.name").
		Joins("INNER JOIN positions ON players.ID = positions.player_id").
		Where("players.id = ?", id)
	rows, err := query.Rows()
	defer rows.Close()

	if err != nil {
		return nil, err
	}

	var playerDisplayWithPosition struct {
		model.PlayerDisplay
		Name string
	}
	var positions []string
	for rows.Next() {
		err := query.ScanRows(rows, &playerDisplayWithPosition)
		if err != nil {
			return nil, err
		}
		positions = append(positions, *&playerDisplayWithPosition.Name)
	}
	playerDisplay := playerDisplayWithPosition.PlayerDisplay
	playerDisplay.Position = positions

	return &playerDisplay, nil
}

コードが複雑化した。こうなるとやっぱり、Preloadとか使ったほうが良かったかと思う。
・・・が、クエリー回数を減らすこと、検索速度を上げることが目的だったのでこうするしかないのかも。
dapperのほうがわかりやすいなぁと思う。
この辺りは、業務要件や速度を考慮して実装方法決めることになるのかなぁ。今回のサンプルなら、Preloadでもなく、Usecaseでプログラムで関連付けるのが良いね。
個人的には、実装方法を状況によって変えるとプログラムの複雑性が増すので好きではない・・・。SQLを書く方法で統一したいなぁ。

Usecase層

  • FindByIDのreturnを変更
  • IPlayerUsecaseを変更
  • IPlayerQueryServiceを注入されるように変更
package usecase

import (
	"go/domain/model"
	"go/domain/queryservice"
	"go/domain/repository"
)

// player usecaseのinterface
type IPlayerUsecase interface {
	Create(firstName, lastName, position, team string) (*model.Player, error)
	FindByID(id int) (*model.PlayerDisplay, error)
	Update(id int, firstName, lastName, position, team string) (*model.Player, error)
	Delete(id int) error
}

type playerUsecase struct {
	playerRepo         repository.IPlayerRepository
	playerQueryService queryservice.IPlayerQueryService
}

// player usecaseのコンストラクタ
func NewPlayerUsecase(playerRepo repository.IPlayerRepository, playerQueryService queryservice.IPlayerQueryService) IPlayerUsecase {
	return &playerUsecase{playerRepo: playerRepo, playerQueryService: playerQueryService}
}

// FindByID playerをIDで取得するときのユースケース
func (usecase *playerUsecase) FindByID(id int) (*model.PlayerDisplay, error) {
	player, err := usecase.playerQueryService.FindByID(id)
	if err != nil {
		return nil, err
	}

	return player, nil
}

interface層

Response jsonの型変更

DI追加

query serviceを注入するように変更。

func main() {
	// DI
	playerRepository := infra.NewPlayerRepository(config.NewDB())
	playerQueryService := infra.NewPlayerDisplayQueryService(config.NewDB())
	playerUsecase := usecase.NewPlayerUsecase(playerRepository, playerQueryService)
	playerHandler := handler.NewPlayerHandler(playerUsecase)

	e := echo.New()
	handler.InitRouting(e, playerHandler)
	e.Logger.Fatal(e.Start(":3000"))
}
type responsePlayer struct {
	ID        int      `json:"id"`
	FirstName string   `json:"first_name"`
	LastName  string   `json:"last_name"`
	Position  []string `json:"position"`
	Team      string   `json:"team"`
}

CQRSまとめ

正直いろんなものがあいまいなまま進めすぎた。うまく実装できたのかはわからないけど、動いたという感じ。
Gormもあいまいなまま進めた。結局、CQRSで実装するとユースケース難民が解決するわけではなかったし、プログラムの複雑性は増すけど、使いどころは理解できた。
実際に仕事で使うとしたら・・・利用すると思う。レスポンスタイムにそんなうるさくないPJなら利用しない。

まとめ

まとめられるほどわかってないけど、試しに作ってみてやっぱり、言葉に振り回されない。概念的なところに振り回されない。
コーディング規約くらいに思って、やってみる。もちろん、引っ張ってくれる人は欲しい。いなければ、勉強会とかする。
だと思うんだけど、勉強会の時間的ロス、勉強不足による混乱のリスクを負ってまでMVCではなくDDDでやるメリットがあるのかも検討する必要はあるかな。

抽象的な言葉で書かれててさらに、英語から日本語に訳されたり、言葉のニュアンスが違ったりでやっぱり難しいなぁと思う。

ここからいろいろ変化を加えてみて、ホントに変化に強いのか、言われているメリットは正しいのか?ってところを見てみたいと思う。
・・・まとめではないですやん。

Special Thanks

プログラムは、ほとんどDDDを意識しながらレイヤードアーキテクチャとGoでAPIサーバーを構築するを参考にさせてもらいました。大変勉強になりました。

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0