概要
Nuxt.js(Vue.js)とレイヤードアーキテクチャのお勉強のために簡単なチャットアプリを実装してみた。
SPA + APIと言った形になっている。
機能
機能はだいたい以下のような感じ。
- ログイン機能
- サインアップ機能
- スレッド一覧表示機能
- スレッド作成機能
- ログインしたユーザーは誰でもスレッドを作成できること
- コメント一覧表示機能
- スレッドをクリックすると、そのスレッド内のコメント一覧が表示されること
- スレッド内でのコメント作成機能
- ログインしたユーザーは誰でもどのスレッド内でもコメントできること
- スレッド内でのコメント削除機能
- 自分のコメントのみ削除できること
- ログアウト機能
コード
- コード全体はここ
- コードは一例でもっと他の実装や良さそうな実装はありそう
技術
サーバーサイド
アーキテクチャ
DDD本に出てくるレイヤードアーキテクチャをベースに以下の書籍や記事を参考にさせていただき実装した。超厳密なレイヤードアーキテクチャというわけではない。
- Goを運用アプリケーションに導入する際のレイヤ構造模索の旅路 | Go Conference 2018 Autumn 発表レポート - BASE開発チームブログ
- GoでのAPI開発現場のアーキテクチャ実装事例 / go-api-architecture-practical-example - Speaker Deck
- ボトムアップドメイン駆動設計 │ nrslib
- エリック・エヴァンス(著)、今関 剛 (監修)、和智 右桂 (翻訳) (2011/4/9)『エリック・エヴァンスのドメイン駆動設計 (IT Architects’Archive ソフトウェア開発の実践)』 翔泳社
- pospome『pospomeのサーバサイドアーキテクチャ
実際のpackage構成は以下のような感じ。
├── interface
│ └── controller // サーバへの入力と出力を扱う責務。
├── application // 作業の調整を行う責務。
├── domain
│ ├── model // ビジネスの概念とビジネスロジック(正直今回はそんなにビジネスロジックない...)
│ ├── service // EntityでもValue Objectでもないドメイン層のロジック。
│ └── repository // infra/dbへのポート。
├── infra // 技術に関すること。
│ ├── db // DBの技術に関すること。
│ ├── logger // Logの技術に関すること。
│ └── router // Routingの技術に関すること。
├── middleware // リクエスト毎に差し込む処理をまとめたミドルウェア
├── util
└── testutil
packageの切り方は以下を大変参考にさせていただいている。
- エリック・エヴァンス(著)、今関 剛 (監修)、和智 右桂 (翻訳) (2011/4/9)『エリック・エヴァンスのドメイン駆動設計 (IT Architects’Archive ソフトウェア開発の実践)』 翔泳社
- pospome『pospomeのサーバサイドアーキテクチャ』
- Goを運用アプリケーションに導入する際のレイヤ構造模索の旅路 | Go Conference 2018 Autumn 発表レポート - BASE開発チームブログ
- ボトムアップドメイン駆動設計 │ nrslib
- GoでのAPI開発現場のアーキテクチャ実装事例 / go-api-architecture-practical-example - Speaker Deck
上記のpackage以外に application/mock
、domain/service/mock
、infra/db/mock
というmockを格納する用のpackageもあり、そこに各々のレイヤーのmock用のファイルを置いている。(詳しくは後述)
依存関係
依存関係としてはざっくり、interface/controller
→ application
→ dmain/repository
or dmain/service
← infra/db
という形になっている。
参考: GoでのAPI開発現場のアーキテクチャ実装事例 / go-api-architecture-practical-example - Speaker Deck
domain/~
と infra/db
で矢印が逆になっているのは、依存関係が逆転しているため。
詳しくは その設計、変更に強いですか?単体テストできますか?...そしてクリーンアーキテクチャ - Qiitaを参照。
先ほどの矢印の中で、domain/model
は記述しなかったが、 domain/model
は、interface/controller
や application
等からも依存されている。純粋なレイヤードアーキテクチャでは、各々のレイヤーは自分の下のレイヤーにのみ依存するといったものがあるかもしれないが、それを実現するためにDTO等を用意する必要があって、今回の実装ではそこまで必要はないかなと思ったためそうした。(厳格にやる場合は、実装した方がいいかもしれない)
各レイヤーでのinterfaceの定義とテスト
applicaion
、 domain/service
、infra/db
(定義先は、/domain/repository
) には interface
を定義し、他のレイヤーからはその interface
に依存させるようにしている。こうするとこれらを使用する側は、抽象に依存するようになるので、抽象を実装する具象を変化させても使用する側(依存する側)はその影響を受けにくい。
実際に各レイヤーを使用する側のレイヤのテストの際には、使用されるレイヤーを実際のコードではなく、Mock用のものに差し替えている。各々のレイヤーに存在する mock
というpackageにmock用のコードを置いている。このモック用のコードは、gomockを使用して自動生成している。
この辺のことについては、
その設計、変更に強いですか?単体テストできますか?...そしてクリーンアーキテクチャ - Qiita という記事を以前書いたので、詳しくはこちらを参照いただきたい。
エラーハンドリング
エラーハンドリングは以下のように行なっている。
- 以下のような形で
errors.Wrap
を使用してオリジナルのエラーを包む
if err := Hoge(); err != nil {
return errors.Wrap(オリジナルエラー, "状況の説明"
}
- 独自のエラー型を定義している
- エラーは基本的に各々のレイヤーで握りつぶさず、
interface/controller
レイヤーまで伝播させる - 最終的には、
interface/controller
でエラーの型によって、レスポンスとして返すメッセージやステータスコードを選択する
参考
Golangのエラー処理とpkg/errors | SOTA
ログイン周り
- 外部サービスを使用せず、自前で簡単なものを実装した
- パスワードのハッシュ化には bcryptを使用した
- 普通にCookieとSessionを使用した
- ログインが必要なAPIには
gin
のmiddleware
を使用して、ログイン済みでないクライアントからのリクエストは401 Unauthorized
を返すようにした
DB周り
- MySQLを使用した
- DBテスト部分は、DBサーバを立てたわけではなく、DATA-DOG/go-sqlmockを使用し、モックで行なった
-
GoのAPIのテストにおける共通処理 – timakin – Mediumにあるように以下等を使用してDBサーバーを立てて行うのも良いかも
-
ory/dockertest
- Dockerを使う場合
-
lestrrat-go/test-mysqld
- Dockerを使わない場合
-
ory/dockertest
-
GoのAPIのテストにおける共通処理 – timakin – Mediumにあるように以下等を使用してDBサーバーを立てて行うのも良いかも
- DB操作周りの実装に関しては、database/sql packageをそのまま使用し、ORMやその他のライブラリは特に使用していない
- トランザクションは、
application
レイヤでかける - 以下のようなSQL周りの
interface
を作成
package query
import (
"context"
"database/sql"
)
// DBManager is the manager of SQL.
type DBManager interface {
SQLManager
Beginner
}
// TxManager is the manager of Tx.
type TxManager interface {
SQLManager
Commit() error
Rollback() error
}
// SQLManager is the manager of DB.
type SQLManager interface {
Querier
Preparer
Executor
}
type (
// Executor is interface of Execute.
Executor interface {
Exec(query string, args ...interface{}) (sql.Result, error)
ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
}
// Preparer is interface of Prepare.
Preparer interface {
Prepare(query string) (*sql.Stmt, error)
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
}
// Querier is interface of Query.
Querier interface {
Query(query string, args ...interface{}) (*sql.Rows, error)
QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
}
// Beginner is interface of Begin.
Beginner interface {
Begin() (TxManager, error)
}
)
-
application
レイヤーでは以下のようにフィールドでquery.DBManager
を所持する- そうすることで
SQLManager
とTxManager
(Begin()
で生成)のどちらもapplication
レイヤーで扱うことができる(application
レイヤで直接使用するわけではなく、domain/repository
に渡す)
- そうすることで
// threadService is application service of thread.
type threadService struct {
m query.DBManager
service service.ThreadService
repo repository.ThreadRepository
txCloser CloseTransaction
}
-
domain/repository
の引数ではquery.SQLManager
を受け取る-
query.TxManager
は、query.SQLManager
も満たしているので、query.TxManager
は、query.SQLManager
のどちらも受け取ることができる
-
// ThreadRepository is Repository of Thread.
type ThreadRepository interface {
ListThreads(ctx context.Context, m query.SQLManager, cursor uint32, limit int) (*model.ThreadList, error)
GetThreadByID(ctx context.Context, m query.SQLManager, id uint32) (*model.Thread, error)
GetThreadByTitle(ctx context.Context, m query.SQLManager, name string) (*model.Thread, error)
InsertThread(ctx context.Context, m query.SQLManager, thead *model.Thread) (uint32, error)
UpdateThread(ctx context.Context, m query.SQLManager, id uint32, thead *model.Thread) error
DeleteThread(ctx context.Context, m query.SQLManager, id uint32) error
}
- 以下のようなRollbackやCommitを行う関数を作成しておく
// CloseTransaction executes post process of tx.
func CloseTransaction(tx query.TxManager, err error) error {
if p := recover(); p != nil { // rewrite panic
err = tx.Rollback()
err = errors.Wrap(err, "failed to roll back")
panic(p)
} else if err != nil {
err = tx.Rollback()
err = errors.Wrap(err, "failed to roll back")
} else {
err = tx.Commit()
err = errors.Wrap(err, "failed to commit")
}
return err
}
-
application
レイヤでは、defer
でCloseTransaction
を呼び出す(ここではa.txCloser
になっている)
// CreateThread creates Thread.
func (a *threadService) CreateThread(ctx context.Context, param *model.Thread) (thread *model.Thread, err error) {
tx, err := a.m.Begin()
if err != nil {
return nil, beginTxErrorMsg(err)
}
defer func() {
if err := a.txCloser(tx, err); err != nil {
err = errors.Wrap(err, "failed to close tx")
}
}()
yes, err := a.service.IsAlreadyExistTitle(ctx, tx, param.Title)
if yes {
err = &model.AlreadyExistError{
PropertyName: model.TitleProperty,
PropertyValue: param.Title,
DomainModelName: model.DomainModelNameThread,
}
return nil, errors.Wrap(err, "already exist id")
}
if _, ok := errors.Cause(err).(*model.NoSuchDataError); !ok {
return nil, errors.Wrap(err, "failed is already exist id")
}
id, err := a.repo.InsertThread(ctx, tx, param)
if err != nil {
return nil, errors.Wrap(err, "failed to insert thread")
}
param.ID = id
return param, nil
}
- 上記の処理ができるように
CloseTransaction
をapplication
レイヤの構造体にDIしておく- Goでは関数もDIできる
// threadService is application service of thread.
type threadService struct {
m query.DBManager
service service.ThreadService
repo repository.ThreadRepository
txCloser CloseTransaction
}
所感
- レイヤードアーキテクチャは
- 依存関係がはっきりするのが良い
- 各レイヤが疎結合なので変更しやすく、テストもしやすいのは良い
- 各レイヤの責務がはっきり別れているので、どこに何を書けばいいかはわかりやすい
- コード量は増えるので、実装に時間がかかる
- 決まったところは自動化できると良いかも
- CRUDだけの小さなアプリケーションでは、大げさすぎるかもしれない
フロントエンド
アーキテクチャ
- 基本的には、Nuxt.jsのアーキテクチャに沿って実装を行なった
- 状態管理に感じては、Vuexを使用した
- 各々の
Component
側(pages
やcomponents
)からデータを使用したい場合には、Vuexを通じて使用した - データ、ロジックとビュー部分が綺麗に別れる
- 各々の
見た目
- Vue.jsに全面的に乗っかった
- コメントの一覧部分のCSSは CSSで作る!吹き出しデザインのサンプル19選 を参考にさせていただいた
大きな流れ
大きな流れとしては、以下のような流れ。
pasges
や components
等のビューでのイベントの発生 → actions
経由でAPIへリクエスト → mutations
で state
変更 → pasges
や components
等のビューに反映される
他の流れもたくさんあるが、代表的なList処理とInput処理の流れを以下に記す。
List処理
-
pages
やcomponents
のasyncData
内で、store.dispatch
を通じて、データ一覧を取得するアクション(actions
)を呼び出す -
store
のactions
内での処理を行う- axiosを使用してAPIにリクエストを送信する
- APIから返却されたデータを引数に
mutations
をcommit
する。
-
mutations
での処理を行う-
state
を変更する
-
-
pages
やcomponents
のビューで取得したデータが表示される
Input処理
-
pages
やcomponents
でstores
に定義したaction
やstate
を読み込んでおく -
pages
やcomponents
のdata
部分とformのinput部分等にv-model
を使用して双方向データバインディングをしておく -
pages
やcomponents
で表示しているビュー部分でイベントが生じる- form入力→submitなど
- sumitする時にクリックされるボタンに
@click=hoge
という形でイベントがそのElementで該当のイベントが生じた時に呼び出されるメソッド等を登録しておく- 上記の例では、
click
イベントが生じるとhoge
メソッドが呼び出される - イベントハンドリング — Vue.js
- 上記の例では、
- 呼び出されたメソッドの処理を行う
- formのデータを元にデータを登録するアクション(
actions
)を呼び出す
- formのデータを元にデータを登録するアクション(
-
store
のactions
内での処理を行う- axiosを使用してAPIにリクエストを送信する
- APIから返却されたデータを引数に
mutations
をcommit
する。
-
mutations
での処理を行う-
state
を変更する - 登録した分のデータを一覧の
state
に追加する
-
-
pages
やcomponents
のビューで登録したデータが追加された一覧表示される
非同期部分
-
async/await
で処理
所感
- Nuxt.jsを使用すると、レールに乗っかれて非常に楽
- どこに何を実装すればいいか明白になるので迷わないで済む
- 特にVuexを使用すると
- データの流れが片方向になるのはわかりやすくて良い
- ビュー、ロジック、データの責務がはっきりするのが良い
- Vuetifyを使用するとあまり凝らない画面であれば、短期間で実装できそう
- Componentの切り方をAtomic Designに則ったやり方とかにするともっといい感じに切り分けられたかもしれない
参考文献
サーバーサイド
- InfoQ.com、徳武 聡(翻訳) (2009年6月7日) 『Domain Driven Design(ドメイン駆動設計) Quickly 日本語版』 InfoQ.com
- エリック・エヴァンス(著)、今関 剛 (監修)、和智 右桂 (翻訳) (2011/4/9)『エリック・エヴァンスのドメイン駆動設計 (IT Architects’Archive ソフトウェア開発の実践)』 翔泳社
- pospome『pospomeのサーバサイドアーキテクチャ』
フロントエンド
- 花谷拓磨 (2018/10/17)『Nuxt.jsビギナーズガイド』シーアンドアール研究所
- 川口 和也、喜多 啓介、野田 陽平、 手島 拓也、 片山 真也(2018/9/22)『Vue.js入門 基礎から実践アプリケーション開発まで』技術評論社
参考にさせていただいた記事
サーバーサイド
-
Goを運用アプリケーションに導入する際のレイヤ構造模索の旅路 | Go Conference 2018 Autumn 発表レポート - BASE開発チームブログ
-
GoでのAPI開発現場のアーキテクチャ実装事例 / go-api-architecture-practical-example - Speaker Deck